Skip to content

Commit d4e0d26

Browse files
committed
Implemented AssetsEndpoint
1 parent a02fc1a commit d4e0d26

17 files changed

+647
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,6 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
131+
# Pycharm
132+
.idea/

open_sea_v1/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""
2+
It's called open_sea_v1 as that is how Open Sea refers to their API.
3+
There could be upcoming API versions which will should then have their own modules.
4+
"""

open_sea_v1/endpoints/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Exposes classes and objects meant to be used by You!
3+
Import other modules at your own risk, as their location may change.
4+
"""
5+
from open_sea_v1.endpoints.endpoint_assets import _AssetsEndpoint as AssetsEndpoint
6+
from open_sea_v1.endpoints.endpoint_assets import _AssetsOrderBy as AssetsOrderBy
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Optional
3+
4+
from requests import Response
5+
6+
7+
class BaseOpenSeaEndpoint(ABC):
8+
9+
@abstractmethod
10+
def get_request(self, url: str, method: str = 'GET', **kwargs) -> Response:
11+
"""Call to super().get_request passing url and _request_params."""
12+
13+
@property
14+
@abstractmethod
15+
def api_key(self) -> Optional[str]:
16+
"""Optional OpenSea API key"""
17+
18+
@property
19+
@abstractmethod
20+
def url(self) -> str:
21+
"""Endpoint URL"""
22+
23+
@property
24+
@abstractmethod
25+
def _request_params(self) -> dict:
26+
"""Dictionnary of _request_params to pass into the get_request."""
27+
28+
@property
29+
@abstractmethod
30+
def validate_request_params(self) -> None:
31+
""""""
32+
33+
@property
34+
@abstractmethod
35+
def response(self) -> list[dict]:
36+
"""Parsed JSON dictionnary from HTTP Response."""
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from dataclasses import dataclass
2+
from enum import Enum
3+
from typing import Optional
4+
5+
from requests import Response
6+
7+
from open_sea_v1.endpoints.endpoint_urls import OpenseaApiEndpoints
8+
from open_sea_v1.endpoints.endpoint_client import OpenSeaClient
9+
from open_sea_v1.endpoints.endpoint_abc import BaseOpenSeaEndpoint
10+
from open_sea_v1.responses.asset_obj import _AssetResponse
11+
12+
13+
class _AssetsOrderBy(str, Enum):
14+
"""
15+
Helper Enum for remembering the possible values for the order_by param of the AssetsEndpoint class.
16+
"""
17+
TOKEN_ID = "token_id"
18+
SALE_DATE = "sale_date"
19+
SALE_COUNT = "sale_count"
20+
VISITOR_COUNT = "visitor_count"
21+
SALE_PRICE = "sale_price"
22+
23+
24+
@dataclass
25+
class _AssetsEndpoint(OpenSeaClient, BaseOpenSeaEndpoint):
26+
"""
27+
Opensea API Assets Endpoint
28+
29+
Parameters
30+
----------
31+
width:
32+
width of the snake
33+
34+
owner:
35+
The address of the owner of the assets
36+
37+
token_ids:
38+
List of token IDs to search for
39+
40+
asset_contract_address:
41+
The NFT contract address for the assets
42+
43+
asset_contract_addresses:
44+
List of contract addresses to search for. Will return a list of assets with contracts matching any of the addresses in this array. If "token_ids" is also specified, then it will only return assets that match each (address, token_id) pairing, respecting order.
45+
46+
order_by:
47+
How to order the assets returned. By default, the API returns the fastest ordering (contract address and token id). Options you can set are token_id, sale_date (the last sale's transaction's timestamp), sale_count (number of sales), visitor_count (number of unique visitors), and sale_price (the last sale's total_price)
48+
49+
order_direction:
50+
Can be asc for ascending or desc for descending
51+
52+
offset:
53+
Offset
54+
55+
limit:
56+
Defaults to 20, capped at 50.
57+
58+
collection:
59+
Limit responses to members of a collection. Case sensitive and must match the collection slug exactly. Will return all assets from all contracts in a collection.
60+
61+
:return: Parsed JSON
62+
"""
63+
api_key: Optional[str] = None
64+
owner: Optional[str] = None
65+
token_ids: Optional[list[int]] = None
66+
asset_contract_address: Optional[list[str]] = None
67+
asset_contract_addresses: Optional[str] = None
68+
collection: Optional[str] = None
69+
order_by: Optional[_AssetsOrderBy] = None
70+
order_direction: str = None
71+
offset: int = 0
72+
limit: int = 20
73+
74+
def __post_init__(self):
75+
self.validate_request_params()
76+
self._response: Optional[Response] = None
77+
78+
@property
79+
def http_response(self):
80+
self._validate_response_property()
81+
return self._response
82+
83+
@property
84+
def response(self) -> list[_AssetResponse]:
85+
self._validate_response_property()
86+
assets_json = self._response.json()['assets']
87+
assets = [_AssetResponse(asset_json) for asset_json in assets_json]
88+
return assets
89+
90+
@property
91+
def url(self):
92+
return OpenseaApiEndpoints.ASSETS.value
93+
94+
def get_request(self, *args, **kwargs):
95+
self._response = super().get_request(self.url, **self._request_params)
96+
97+
@property
98+
def _request_params(self) -> dict[dict]:
99+
params = dict(
100+
owner=self.owner, token_ids=self.token_ids, asset_contract_address=self.asset_contract_address,
101+
asset_contract_addresses=self.asset_contract_addresses, collection=self.collection,
102+
order_by=self.order_by, order_direction=self.order_direction, offset=self.offset, limit=self.limit
103+
)
104+
return dict(api_key=self.api_key, params=params)
105+
106+
def validate_request_params(self) -> None:
107+
self._validate_mandatory_params()
108+
self._validate_asset_contract_addresses()
109+
self._validate_order_direction()
110+
self._validate_order_by()
111+
self._validate_limit()
112+
113+
def _validate_response_property(self):
114+
if self._response is None:
115+
raise AttributeError('You must call self.request prior to accessing self.response')
116+
117+
def _validate_mandatory_params(self):
118+
mandatory = self.owner, self.token_ids, self.asset_contract_address, self.asset_contract_addresses, self.collection
119+
if all((a is None for a in mandatory)):
120+
raise ValueError("At least one of the following parameters must not be None:\n"
121+
"owner, token_ids, asset_contract_address, asset_contract_addresses, collection")
122+
123+
def _validate_asset_contract_addresses(self):
124+
if self.asset_contract_address and self.asset_contract_addresses:
125+
raise ValueError(
126+
"You cannot simultaneously get_request for a single contract_address and a list of contract_addresses."
127+
)
128+
129+
if self.token_ids and not (self.asset_contract_address or self.asset_contract_addresses):
130+
raise ValueError(
131+
"You cannot query for token_ids without specifying either "
132+
"asset_contract_address or asset_contract_addresses."
133+
)
134+
135+
def _validate_order_direction(self):
136+
if self.order_direction is None:
137+
return
138+
139+
if self.order_direction not in ['asc', 'desc']:
140+
raise ValueError(
141+
f"order_direction param value ({self.order_direction}) is invalid. "
142+
f"Must be either 'asc' or 'desc', case sensitive."
143+
)
144+
145+
def _validate_order_by(self) -> None:
146+
if self.order_by is None:
147+
return
148+
149+
if self.order_by not in (_AssetsOrderBy.TOKEN_ID, _AssetsOrderBy.SALE_COUNT, _AssetsOrderBy.SALE_DATE, _AssetsOrderBy.SALE_PRICE, _AssetsOrderBy.VISITOR_COUNT):
150+
raise ValueError(
151+
f"order_by param value ({self.order_by}) is invalid. "
152+
f"Must be a value from {_AssetsOrderBy.list()}, case sensitive."
153+
)
154+
155+
def _validate_limit(self):
156+
if not isinstance(self.limit, int) or not 0 <= self.limit <= 50:
157+
raise ValueError(f"limit param must be an int between 0 and 50.")
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from requests import Response, request
2+
3+
4+
class OpenSeaClient:
5+
6+
api_key = None
7+
8+
@property
9+
def http_headers(self) -> dict:
10+
return {
11+
"headers":
12+
{"X-API-Key" : self.api_key} if self.api_key else dict(),
13+
}
14+
15+
def get_request(self, url: str, method: str = 'GET', **kwargs) -> Response:
16+
"""
17+
Automatically passes in API key in HTTP get_request headers.
18+
"""
19+
if 'api_key' in kwargs:
20+
self.api_key = kwargs.pop('api_key')
21+
updated_kwargs = kwargs | self.http_headers
22+
return request(method, url, **updated_kwargs)
23+
24+
# def collections(self, *, asset_owner: Optional[str] = None, offset: int, limit: int) -> OpenseaCollections:
25+
# """
26+
# Use this endpoint to fetch collections and dapps that OpenSea shows on opensea.io,
27+
# along with dapps and smart contracts that a particular user cares about.
28+
#
29+
# :param asset_owner: A wallet address. If specified, will return collections where
30+
# the owner owns at least one asset belonging to smart contracts in the collection.
31+
# The number of assets the account owns is shown as owned_asset_count for each collection.
32+
# :param offset: For pagination. Number of contracts offset from the beginning of the result list.
33+
# :param limit: For pagination. Maximum number of contracts to return.
34+
# :return: Parsed JSON
35+
# """
36+
# if offset != 0:
37+
# raise NotImplementedError(
38+
# "Sorry, tested offset parameter is not implemented yet. "
39+
# "Feel free to PR after looking at the tests and trying to understand"
40+
# " why current implementation doesn't allow pagination to work..."
41+
# )
42+
# resp = self._collections(asset_owner=asset_owner, offset=offset, limit=limit)
43+
# return resp.json()['collections']
44+
#
45+
# def _collections(self, **_request_params) -> Response:
46+
# """Returns HTTPResponse object."""
47+
# url = OpenseaApiEndpoints.COLLECTIONS.value
48+
# return self.get_request("GET", url, _request_params=_request_params)
49+
#
50+
# def asset(self, asset_contract_address: str, token_id: str, account_address: Optional[str] = None) -> OpenseaAsset:
51+
# """
52+
# :param asset_contract_address: Address of the contract for this NFT
53+
# :param token_id: Token ID for this item
54+
# :param account_address: Address of an owner of the token. If you include this, the http_response will include an ownership object that includes the number of tokens owned by the address provided instead of the top_ownerships object included in the standard http_response, which provides the number of tokens owned by each of the 10 addresses with the greatest supply of the token.
55+
# :return: Parsed JSON.
56+
# """
57+
# resp = self._asset(
58+
# asset_contract_address=asset_contract_address,
59+
# token_id=token_id,
60+
# account_address=account_address,
61+
# )
62+
# return resp.response()
63+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from enum import Enum
2+
3+
OPENSEA_API_V1 = "https://api.opensea.io/api/v1/"
4+
OPENSEA_LISTINGS_V1 = "https://api.opensea.io/wyvern/v1/"
5+
6+
7+
class OpenseaApiEndpoints(str, Enum):
8+
ASSET = OPENSEA_API_V1 + "asset"
9+
ASSETS = OPENSEA_API_V1 + "assets"
10+
ASSET_CONTRACT = OPENSEA_API_V1 + "asset_contract"
11+
BUNDLES = OPENSEA_API_V1 + "bundles"
12+
EVENTS = OPENSEA_API_V1 + "events"
13+
COLLECTIONS = OPENSEA_API_V1 + "collections"
14+
LISTINGS = OPENSEA_LISTINGS_V1 + "orders"

open_sea_v1/endpoints/tests/__init__.py

Whitespace-only changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from itertools import combinations
2+
from unittest import TestCase
3+
4+
from open_sea_v1.endpoints.endpoint_assets import _AssetsEndpoint, _AssetsOrderBy
5+
from open_sea_v1.responses.asset_obj import _AssetResponse
6+
7+
8+
class TestAssetsRequest(TestCase):
9+
sample_contract = "0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb" # punk
10+
sample_wallet = "0x5ca12f79e4d33b0bd153b40df59f6db9ee03482e" # punk
11+
default_asset_params = dict(token_ids=[5, 6, 7], asset_contract_address=sample_contract)
12+
13+
@staticmethod
14+
def create_and_get(**kwargs) -> list[_AssetResponse]:
15+
"""Shortcut"""
16+
client = _AssetsEndpoint(**kwargs)
17+
client.get_request()
18+
return client.response
19+
20+
def test_cannot_all_be_none_owner_token_ids_asset_contract_address_asset_contract_addresses_collection(self):
21+
assets_kwargs = dict(owner=0, token_ids=0, collection=0, asset_contract_address=0, asset_contract_addresses=0)
22+
for kwarg_combo in combinations(assets_kwargs, r=len(assets_kwargs)):
23+
self.assertRaises(ValueError, _AssetsEndpoint, kwarg_combo)
24+
25+
def test_param_owner_returns_assets_from_specified_owner(self):
26+
params = dict(owner=self.sample_wallet, order_direction='asc', **self.default_asset_params)
27+
for punk in self.create_and_get(**params):
28+
self.assertEqual(punk.owner.address, self.sample_wallet)
29+
30+
def test_param_token_ids_raise_exception_if_missing_contract_address_and_addresses(self):
31+
self.assertRaises(ValueError, _AssetsEndpoint, token_ids=[1, 2, 3])
32+
33+
def test_params_cannot_be_simultaneously_be_passed_asset_contract_address_and_contract_addresses(self):
34+
params = self.default_asset_params | dict(asset_contract_address=True, asset_contract_addresses=True)
35+
self.assertRaises(ValueError, _AssetsEndpoint, **params)
36+
37+
def test_param_token_ids_returns_assets_corresponding_to_single_contract(self):
38+
params = dict(order_direction='asc', **self.default_asset_params)
39+
for punk in self.create_and_get(**params):
40+
self.assertEqual(punk.asset_contract.address, self.sample_contract)
41+
42+
def test_param_order_direction_can_only_be_asc_or_desc(self):
43+
invalid_order_values = (False, 0, 1, "", [], (), {}, 'hi')
44+
for invalid_order in invalid_order_values:
45+
params = dict(token_ids=[1], asset_contract_address=self.sample_contract, order_direction=invalid_order)
46+
self.assertRaises((ValueError, TypeError), _AssetsEndpoint, **params)
47+
48+
def test_param_order_by_token_id(self):
49+
params = self.default_asset_params | dict(token_ids=[3, 2, 1], order_by=_AssetsOrderBy.TOKEN_ID, order_direction='desc')
50+
punks_ids = [punk.token_id for punk in self.create_and_get(**params)]
51+
self.assertEqual(['3', '2', '1'], punks_ids)
52+
53+
def test_param_order_by_sale_date(self):
54+
params = self.default_asset_params | dict(token_ids=[1, 14, 33], order_by=_AssetsOrderBy.SALE_DATE)
55+
punks_sales = [punk.last_sale.event_timestamp for punk in self.create_and_get(**params)]
56+
self.assertEqual(sorted(punks_sales, reverse=True), punks_sales)
57+
58+
def test_param_order_by_sale_count(self):
59+
params = self.default_asset_params | dict(token_ids=[1, 14, 33], order_by=_AssetsOrderBy.SALE_COUNT)
60+
punks_sales_cnt = [punk.num_sales for punk in self.create_and_get(**params)]
61+
self.assertEqual(sorted(punks_sales_cnt, reverse=True), punks_sales_cnt)
62+
63+
def test_param_order_by_sale_price(self):
64+
params = self.default_asset_params | dict(token_ids=[1, 14, 33], order_by=_AssetsOrderBy.SALE_PRICE)
65+
punks_last_sale_price = [punk.last_sale.total_price for punk in self.create_and_get(**params)]
66+
self.assertEqual(sorted(punks_last_sale_price, reverse=True), punks_last_sale_price)
67+
68+
def test_param_order_by_visitor_count(self):
69+
pass # as far as I know this is not returned in the API http_response and done directly by OpenSea
70+
71+
def test_param_limit_cannot_be_below_0_or_above_50(self):
72+
self.assertRaises(ValueError, _AssetsEndpoint, limit="25", **self.default_asset_params)
73+
self.assertRaises(ValueError, _AssetsEndpoint, limit=-1, **self.default_asset_params)
74+
self.assertRaises(ValueError, _AssetsEndpoint, limit=51, **self.default_asset_params)

0 commit comments

Comments
 (0)