diff --git a/README.rst b/README.rst index 8d8780d..6422275 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,10 @@ Connecting # Public apps (OAuth) # Access_token is optional, if you don't have one you can use oauth_fetch_token (see below) + # For connecting to the v2 api: api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='') + # For connecting to the v3 api: + api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='', api_path='/stores/{}/v3/{}')) # Private apps (Basic Auth) api = bigcommerce.api.BigcommerceApi(host='store.mybigcommerce.com', basic_auth=('username', 'api token')) diff --git a/bigcommerce/api.py b/bigcommerce/api.py index 057a4d3..404a71c 100644 --- a/bigcommerce/api.py +++ b/bigcommerce/api.py @@ -5,7 +5,7 @@ class BigcommerceApi(object): - def __init__(self, host=None, basic_auth=None, + def __init__(self, host=None, api_path=None, basic_auth=None, client_id=None, store_hash=None, access_token=None, rate_limiting_management=None): self.api_service = os.getenv('BC_API_ENDPOINT', 'api.bigcommerce.com') self.auth_service = os.getenv('BC_AUTH_SERVICE', 'login.bigcommerce.com') @@ -14,6 +14,7 @@ def __init__(self, host=None, basic_auth=None, self.connection = connection.Connection(host, basic_auth) elif client_id and store_hash: self.connection = connection.OAuthConnection(client_id, store_hash, access_token, self.api_service, + api_path=api_path, rate_limiting_management=rate_limiting_management) else: raise Exception("Must provide either (client_id and store_hash) or (host and basic_auth)") diff --git a/bigcommerce/connection.py b/bigcommerce/connection.py index 7bffb89..ea1e4c9 100644 --- a/bigcommerce/connection.py +++ b/bigcommerce/connection.py @@ -53,6 +53,13 @@ def _run_method(self, method, url, data=None, query=None, headers=None): if headers is None: headers = {} + # Support v3 + if self.api_path and 'v3' in self.api_path: + if url is 'orders': + self.api_path = self.api_path.replace('v3', 'v2') + else: + url = 'catalog/{}'.format(url) + # make full path if not given if url and url[:4] != "http": if url[0] == '/': # can call with /resource if you want @@ -156,6 +163,9 @@ def _handle_response(self, url, res, suppress_empty=True): if res.status_code in (200, 201, 202): try: result = res.json() + # Support v3 + if self.api_path and 'v3' in self.api_path: + result = result['data'] # TODO ignore meta field for now except Exception as e: # json might be invalid, or store might be down e.message += " (_handle_response failed to decode JSON: " + str(res.content) + ")" raise # TODO better exception @@ -187,11 +197,12 @@ class OAuthConnection(Connection): """ def __init__(self, client_id, store_hash, access_token=None, host='api.bigcommerce.com', - api_path='/stores/{}/v2/{}', rate_limiting_management=None): + api_path=None, rate_limiting_management=None): self.client_id = client_id self.store_hash = store_hash self.host = host self.api_path = api_path + self.api_path = api_path if api_path else "/stores/{}/v2/{}" self.timeout = 7.0 # can attach to session? self.rate_limiting_management = rate_limiting_management @@ -213,6 +224,22 @@ def _oauth_headers(cid, atoken): return {'X-Auth-Client': cid, 'X-Auth-Token': atoken} + def make_request(self, *args, **kwargs): + try: + return super(OAuthConnection, self).make_request(*args, **kwargs) + except RateLimitingException as exc: + response = exc.response + autoretry = (self.rate_limiting_management + and self.rate_limiting_management.get('wait') + and self.rate_limiting_management.get('autoretry')) + + if (autoretry and 'X-Rate-Limit-Time-Reset-Ms' in response.headers): + sleep(ceil(float(response.headers['X-Rate-Limit-Time-Reset-Ms'])/1000)) + return super(OAuthConnection, self).make_request( + *args, **kwargs) + else: + raise + @staticmethod def verify_payload(signed_payload, client_secret): """ diff --git a/bigcommerce/resources/__init__.py b/bigcommerce/resources/__init__.py index 541fc4f..c5b0f0d 100644 --- a/bigcommerce/resources/__init__.py +++ b/bigcommerce/resources/__init__.py @@ -20,4 +20,5 @@ from .store import * from .tax_classes import * from .time import * +from .variants import * from .webhooks import * diff --git a/bigcommerce/resources/products.py b/bigcommerce/resources/products.py index d569de9..5be997c 100644 --- a/bigcommerce/resources/products.py +++ b/bigcommerce/resources/products.py @@ -48,11 +48,17 @@ def rules(self, id=None): else: return ProductRules.all(self.id, connection=self._connection) - def skus(self, id=None): + def skus(self, id=None, **kwargs): if id: - return ProductSkus.get(self.id, id, connection=self._connection) + return ProductSkus.get(self.id, id, connection=self._connection, **kwargs) else: - return ProductSkus.all(self.id, connection=self._connection) + return ProductSkus.all(self.id, connection=self._connection, **kwargs) + + def variants(self, id=None, **kwargs): + if id: + return ProductVariants.get(self.id, id, connection=self._connection, **kwargs) + else: + return ProductVariants.all(self.id, connection=self._connection, **kwargs) def videos(self, id=None): if id: @@ -99,7 +105,9 @@ class ProductImages(ListableApiSubResource, CreateableApiSubResource, count_resource = 'products/images' -class ProductOptions(ListableApiSubResource): +class ProductOptions(ListableApiSubResource, CreateableApiSubResource, + UpdateableApiSubResource, DeleteableApiSubResource, + CollectionDeleteableApiSubResource, CountableApiSubResource): resource_name = 'options' parent_resource = 'products' parent_key = 'product_id' @@ -132,6 +140,15 @@ class ProductSkus(ListableApiSubResource, CreateableApiSubResource, count_resource = 'products/skus' +class ProductVariants(ListableApiSubResource, CreateableApiSubResource, + UpdateableApiSubResource, DeleteableApiSubResource, + CollectionDeleteableApiSubResource, CountableApiSubResource): + resource_name = 'variants' + parent_resource = 'products' + parent_key = 'product_id' + count_resource = 'products/variants' + + class ProductVideos(ListableApiSubResource, CountableApiSubResource, CreateableApiSubResource, DeleteableApiSubResource, CollectionDeleteableApiSubResource): diff --git a/bigcommerce/resources/variants.py b/bigcommerce/resources/variants.py new file mode 100644 index 0000000..d45cbd4 --- /dev/null +++ b/bigcommerce/resources/variants.py @@ -0,0 +1,10 @@ +from .base import * + + +class Variants(ListableApiResource, CreateableApiSubResource, + UpdateableApiSubResource, DeleteableApiSubResource, + CollectionDeleteableApiSubResource, CountableApiSubResource): + resource_name = 'variants' + parent_resource = 'products' + parent_key = 'product_id' + count_resource = 'products/variants' diff --git a/tests/test_connection.py b/tests/test_connection.py index c3c57bd..94a0b2c 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,7 +1,7 @@ import json import unittest from bigcommerce.connection import Connection, OAuthConnection -from bigcommerce.exception import ServerException, ClientRequestException, RedirectionException +from bigcommerce.exception import ServerException, ClientRequestException, RedirectionException, RateLimitingException from mock import patch, MagicMock @@ -152,3 +152,37 @@ def test_fetch_token(self): }, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) + + def test_handle_rate_limit(self): + client_id = 'abc123' + client_secret = '123abc' + code = 'hellosecret' + context = 'stores/abc' + scope = 'store_v2_products' + redirect_uri = 'http://localhost/callback' + result = {'access_token': '12345abcdef'} + connection = OAuthConnection( + client_id, + store_hash='abc', + rate_limiting_management={ + 'wait': True, + 'autoretry': True + } + ) + connection._run_method = MagicMock() + connection._run_method.return_value = MagicMock( + status_code=429, + reason='foo', + headers={ + 'X-Rate-Limit-Time-Reset-Ms': '300', + 'X-Rate-Limit-Time-Window-Ms': '5000', + 'X-Rate-Limit-Requests-Left': '6', + 'X-Rate-Limit-Requests-Quota': '25' + }, + content='' + ) + + with self.assertRaises(RateLimitingException ): + connection.make_request('POST', 'wathever') + + self.assertEqual(connection._run_method.call_count, 2)