├── tests ├── __init__.py ├── test_api.py ├── test_connection.py └── test_base.py ├── MANIFEST.in ├── .gitignore ├── bigcommerce ├── resources │ ├── time.py │ ├── store.py │ ├── tax_classes.py │ ├── payments.py │ ├── shipping.py │ ├── order_statuses.py │ ├── webhooks.py │ ├── currencies.py │ ├── banners.py │ ├── brands.py │ ├── pages.py │ ├── coupons.py │ ├── blog_posts.py │ ├── gift_certificates.py │ ├── redirects.py │ ├── categories.py │ ├── customer_groups.py │ ├── countries.py │ ├── __init__.py │ ├── options.py │ ├── option_sets.py │ ├── customers.py │ ├── orders.py │ ├── products.py │ └── base.py ├── __init__.py ├── exception.py ├── customer_login_token.py ├── api.py └── connection.py ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release.yml ├── requirements.txt ├── examples ├── ex_time.py ├── ex_basic.py └── ex_login_token.py ├── LICENSE.txt ├── setup.py ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.md *.rst 2 | exclude scripts/* 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | ENV/ 3 | 4 | #PyCharm 5 | .idea/ 6 | 7 | Pipfile 8 | Pipfile.lock 9 | 10 | *.egg-info* 11 | venv/ -------------------------------------------------------------------------------- /bigcommerce/resources/time.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Time(ListableApiResource): 5 | resource_name = 'time' 6 | -------------------------------------------------------------------------------- /bigcommerce/resources/store.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Store(ListableApiResource): 5 | resource_name = 'store' 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected behavior 2 | 3 | 4 | ### Actual behavior 5 | 6 | 7 | ### Steps to reproduce behavior 8 | 9 | 10 | -------------------------------------------------------------------------------- /bigcommerce/resources/tax_classes.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class TaxClasses(ListableApiResource): 5 | resource_name = 'tax_classes' 6 | -------------------------------------------------------------------------------- /bigcommerce/resources/payments.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class PaymentMethods(ListableApiResource): 5 | resource_name = 'payments/methods' 6 | -------------------------------------------------------------------------------- /bigcommerce/resources/shipping.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class ShippingMethods(ListableApiResource): 5 | resource_name = 'shipping/methods' 6 | -------------------------------------------------------------------------------- /bigcommerce/resources/order_statuses.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class OrderStatuses(ListableApiResource): 5 | resource_name = 'order_statuses' 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML==5.4.1 2 | cov-core==1.15.0 3 | coverage==5.5 4 | mock==4.0.3 5 | nose==1.3.7 6 | nose-cov==1.6 7 | requests==2.31.0 8 | pyjwt==2.4.0 9 | -------------------------------------------------------------------------------- /bigcommerce/__init__.py: -------------------------------------------------------------------------------- 1 | import bigcommerce.resources 2 | import bigcommerce.api 3 | from bigcommerce.customer_login_token import CustomerLoginTokens as customer_login_token 4 | -------------------------------------------------------------------------------- /bigcommerce/resources/webhooks.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Webhooks(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource): 6 | resource_name = 'hooks' 7 | -------------------------------------------------------------------------------- /examples/ex_time.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import bigcommerce.api 3 | 4 | api = bigcommerce.api.BigcommerceApi(client_id='id', store_hash='hash', access_token='token') 5 | 6 | print(repr(api.Time.all())) 7 | -------------------------------------------------------------------------------- /bigcommerce/resources/currencies.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Currencies(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource): 6 | resource_name = 'currencies' 7 | -------------------------------------------------------------------------------- /bigcommerce/resources/banners.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Banners(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource): 7 | resource_name = 'banners' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/brands.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Brands(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'brands' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/pages.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Pages(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'pages' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/coupons.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Coupons(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'coupons' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/blog_posts.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class BlogPosts(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CountableApiResource, CollectionDeleteableApiResource): 7 | resource_name = 'blog/posts' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/gift_certificates.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class GiftCertificates(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource): 7 | resource_name = 'gift_certificates' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/redirects.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Redirects(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'redirects' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/categories.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Categories(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'categories' 8 | -------------------------------------------------------------------------------- /bigcommerce/resources/customer_groups.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class CustomerGroups(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'customer_groups' 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What? 2 | 3 | A description about what this pull request implements and its purpose. Try to be detailed and describe any technical details to simplify the job of the reviewer and the individual on production support. 4 | 5 | #### Tickets / Documentation 6 | 7 | Add links to any relevant tickets and documentation. 8 | 9 | - [Link 1](http://example.com) 10 | - ... 11 | 12 | #### Screenshots (if appropriate) 13 | 14 | Attach images or add image links here. 15 | 16 | ![Example Image](http://placehold.it/300x200) 17 | -------------------------------------------------------------------------------- /bigcommerce/resources/countries.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Countries(ListableApiResource, CountableApiResource): 5 | resource_name = 'countries' 6 | 7 | def states(self, id=None): 8 | if id: 9 | return CountryStates.get(self.id, id, connection=self._connection) 10 | else: 11 | return CountryStates.all(self.id, connection=self._connection) 12 | 13 | 14 | class CountryStates(ListableApiSubResource, CountableApiSubResource): 15 | resource_name = 'states' 16 | parent_resource = 'countries' 17 | parent_key = 'country_id' 18 | -------------------------------------------------------------------------------- /examples/ex_basic.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import bigcommerce.api 3 | 4 | api = bigcommerce.api.BigcommerceApi(client_id='id', store_hash='hash', access_token='token') 5 | 6 | products = api.Products.all(is_visible=True) 7 | 8 | custom = api.ProductCustomFields.create(products[0].id, name="Manufactured in", text="Australia") 9 | 10 | custom.update(text="USA", name="Manufactured in") 11 | 12 | print(api.ProductCustomFields.get(products[0].id, custom.id)) 13 | 14 | print(products[0].custom_fields(custom.id).delete()) 15 | 16 | print(api.Countries.all(country="Australia")[0].states()[0].parent_id()) 17 | -------------------------------------------------------------------------------- /bigcommerce/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .banners import * 2 | from .blog_posts import * 3 | from .brands import * 4 | from .categories import * 5 | from .countries import * 6 | from .coupons import * 7 | from .currencies import * 8 | from .customer_groups import * 9 | from .customers import * 10 | from .gift_certificates import * 11 | from .option_sets import * 12 | from .options import * 13 | from .order_statuses import * 14 | from .orders import * 15 | from .pages import * 16 | from .payments import * 17 | from .products import * 18 | from .redirects import * 19 | from .shipping import * 20 | from .store import * 21 | from .tax_classes import * 22 | from .time import * 23 | from .webhooks import * 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | push: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [windows-latest, ubuntu-latest, macos-latest] 15 | python-version: [3.7, 3.8, 3.9] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install Python dependencies 23 | uses: py-actions/py-dependency-install@v3 24 | - name: Run Tests 25 | run: nosetests -a '!broken' 26 | -------------------------------------------------------------------------------- /bigcommerce/resources/options.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Options(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'options' 8 | 9 | def values(self, id=None): 10 | if id: 11 | return OptionValues.get(self.id, id, connection=self._connection) 12 | else: 13 | return OptionValues.all(self.id, connection=self._connection) 14 | 15 | 16 | class OptionValues(ListableApiSubResource, CreateableApiSubResource, 17 | UpdateableApiSubResource, DeleteableApiSubResource, 18 | CollectionDeleteableApiSubResource): 19 | resource_name = 'values' 20 | parent_resource = 'options' 21 | parent_key = 'option_id' 22 | -------------------------------------------------------------------------------- /bigcommerce/resources/option_sets.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class OptionSets(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'option_sets' 8 | 9 | def options(self, id=None): 10 | if id: 11 | return OptionSetOptions.get(self.id, id, connection=self._connection) 12 | else: 13 | return OptionSetOptions.all(self.id, connection=self._connection) 14 | 15 | 16 | class OptionSetOptions(ListableApiSubResource, CreateableApiSubResource, 17 | UpdateableApiSubResource, DeleteableApiSubResource, 18 | CollectionDeleteableApiSubResource): 19 | resource_name = 'options' 20 | parent_resource = 'option_sets' 21 | parent_key = 'option_set_id' 22 | -------------------------------------------------------------------------------- /bigcommerce/resources/customers.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Customers(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'customers' 8 | 9 | def addresses(self, id=None): 10 | if id: 11 | return CustomerAddresses.get(self.id, id, connection=self._connection) 12 | else: 13 | return CustomerAddresses.all(self.id, connection=self._connection) 14 | 15 | 16 | class CustomerAddresses(ListableApiSubResource, CreateableApiSubResource, 17 | UpdateableApiSubResource, DeleteableApiSubResource, 18 | CollectionDeleteableApiSubResource, CountableApiSubResource): 19 | resource_name = 'addresses' 20 | parent_resource = 'customers' 21 | parent_key = 'customer_id' 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.x" 17 | - name: Install pypa/build 18 | run: >- 19 | python -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: >- 25 | python -m 26 | build 27 | --sdist 28 | --wheel 29 | --outdir dist/ 30 | - name: Publish distribution 📦 to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) BigCommerce, 2021. 2 | All rights reserved. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /examples/ex_login_token.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import bigcommerce.api 3 | import bigcommerce.customer_login_token 4 | import os 5 | 6 | # Customer login tokens must be signed with an app secret loaded in the environment 7 | os.environ['APP_CLIENT_SECRET'] = 'client secret' 8 | 9 | # Create API object using OAuth credentials 10 | api = bigcommerce.api.BigcommerceApi(client_id='id', store_hash='hash', access_token='token') 11 | 12 | # Create a new customer 13 | api.Customers.create(first_name='Bob', last_name='Johnson', email='bob.johnson@example.com') 14 | 15 | # Or get the customer if they already exist 16 | customer = api.Customers.all(email='bob.johnson@example.com')[0] 17 | 18 | # Create the JWT login token 19 | login_token = bigcommerce.customer_login_token.create(api, customer.id) 20 | 21 | print('Token: %s' % login_token) 22 | 23 | # You can build the URL yourself 24 | print('%s/login/token/%s' % ('https://domain.com', login_token)) 25 | 26 | # Or use the helper method to build the URL. This uses 1 API request to get the secure domain for the store, 27 | # and another API request if you opt to use BC's clock for the iat. 28 | login_token_url = bigcommerce.customer_login_token.create_url(api, customer.id, use_bc_time=True) 29 | print('Token URL: %s' % login_token_url) 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | # Utility function to read the README file. 6 | def read(fname): 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | 9 | 10 | VERSION = '0.23.4' 11 | 12 | setup( 13 | name='bigcommerce', 14 | version=VERSION, 15 | 16 | packages=find_packages(), 17 | install_requires=['requests>=2.25.1', 'pyjwt>=2.0.1'], 18 | 19 | url='https://github.com/bigcommerce/bigcommerce-api-python', 20 | download_url='https://pypi.python.org/packages/source/b/bigcommerce/bigcommerce-{}.tar.gz'.format(VERSION), 21 | 22 | author='Bigcommerce Engineering', 23 | author_email='api@bigcommerce.com', 24 | 25 | description='Connect Python applications with the Bigcommerce API', 26 | long_description=read('README.rst'), 27 | license='MIT', 28 | 29 | keywords=['bigcommerce', 'api', 'v2', 'client'], 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | 'Topic :: Office/Business', 34 | 'Topic :: Internet :: WWW/HTTP', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.6', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: 3.9' 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /bigcommerce/exception.py: -------------------------------------------------------------------------------- 1 | class HttpException(Exception): 2 | """ 3 | Class for representing http errors. Contains the response. 4 | """ 5 | def __init__(self, msg, res): 6 | super(Exception, self).__init__(msg) 7 | self.response = res 8 | 9 | @property 10 | def status_code(self): 11 | return self.response.status_code 12 | 13 | @property 14 | def headers(self): 15 | return self.response.headers 16 | 17 | @property 18 | def content(self): 19 | return self.response.content 20 | 21 | 22 | # 204 23 | class EmptyResponseWarning(HttpException): 24 | pass 25 | 26 | 27 | # 4xx codes 28 | class ClientRequestException(HttpException): 29 | pass 30 | 31 | class RateLimitingException(ClientRequestException): 32 | @property 33 | def retry_after(self): 34 | return self.response.headers['X-Rate-Limit-Time-Reset-Ms'] 35 | 36 | pass 37 | # class Unauthorised(ClientRequestException): pass 38 | # class AccessForbidden(ClientRequestException): pass 39 | # class ResourceNotFound(ClientRequestException): pass 40 | # class ContentNotAcceptable(ClientRequestException): pass 41 | 42 | 43 | # 5xx codes 44 | class ServerException(HttpException): 45 | pass 46 | # class ServiceUnavailable(ServerException): pass 47 | # class StorageCapacityError(ServerException): pass 48 | # class BandwidthExceeded(ServerException): pass 49 | 50 | # 405 and 501 - still just means the client has to change their request 51 | # class UnsupportedRequest(ClientRequestException, ServerException): pass 52 | 53 | 54 | # 3xx codes 55 | class RedirectionException(HttpException): pass 56 | 57 | class NotLoggedInException(Exception): pass 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the BigCommerce Python API Client 2 | 3 | Thanks for showing interest in contributing! 4 | 5 | The following is a set of guidelines for contributing to the BigCommerce Python API client. These are just guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 6 | 7 | By contributing to the BigCommerce Python API client, you agree that your contributions will be licensed under its MIT license. 8 | 9 | #### Table of Contents 10 | 11 | [API Documentation](https://developer.bigcommerce.com/api) 12 | 13 | [How Can I Contribute?](#how-can-i-contribute) 14 | * [Your First Code Contribution](#your-first-code-contribution) 15 | * [Pull Requests](#pull-requests) 16 | 17 | [Styleguides](#styleguides) 18 | * [Git Commit Messages](#git-commit-messages) 19 | * [Python Styleguide](#python-styleguide) 20 | 21 | ### Your First Code Contribution 22 | 23 | Unsure where to begin contributing to the API client? Check our [forums](https://forum.bigcommerce.com/s/group/0F913000000HLjECAW), our [stackoverflow](https://stackoverflow.com/questions/tagged/bigcommerce) tag, and the reported [issues](https://github.com/bigcommerce/bigcommerce-api-python/issues). 24 | 25 | ### Pull Requests 26 | 27 | * Fill in [the required template](https://github.com/bigcommerce/bigcommerce-api-python/pull/new/master) 28 | * Include screenshots and animated GIFs in your pull request whenever possible. 29 | * End files with a newline. 30 | 31 | ## Styleguides 32 | 33 | ### Git Commit Messages 34 | 35 | * Use the present tense ("Add feature" not "Added feature") 36 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 37 | * Limit the first line to 72 characters or less 38 | * Reference pull requests and external links liberally 39 | 40 | ### Python Styleguide 41 | 42 | All Python must adhere to [PEP8 Python Code Styleguide](https://www.python.org/dev/peps/pep-0008/). 43 | 44 | -------------------------------------------------------------------------------- /bigcommerce/customer_login_token.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import uuid 4 | from jwt import encode 5 | 6 | 7 | class CustomerLoginTokens(object): 8 | @classmethod 9 | def create(cls, client, customer_id: int, redirect_url=None, request_ip=None, iat_time=None, channel_id: int = 1): 10 | 11 | # Get the client_secret needed to sign tokens from the environment 12 | # Intended to play nice with the Python Hello World sample app 13 | # https://github.com/bigcommerce/hello-world-app-python-flask 14 | client_secret = os.getenv('APP_CLIENT_SECRET') 15 | 16 | if not client_secret: 17 | raise AttributeError('No OAuth client secret specified in the environment, ' 18 | 'please specify an APP_CLIENT_SECRET') 19 | 20 | try: 21 | client_id = client.connection.client_id 22 | store_hash = client.connection.store_hash 23 | except AttributeError: 24 | raise AttributeError('Store hash or client ID not found in the connection - ' 25 | 'make sure an OAuth API connection is configured. Basic auth is not supported.') 26 | 27 | payload = dict(iss=client_id, 28 | iat=int(time.time()), 29 | jti=uuid.uuid4().hex, 30 | operation='customer_login', 31 | store_hash=store_hash, 32 | customer_id=customer_id, 33 | channel_id=channel_id 34 | ) 35 | 36 | if iat_time: 37 | payload['iat'] = iat_time 38 | 39 | if redirect_url: 40 | payload['redirect_to'] = redirect_url 41 | 42 | if request_ip: 43 | payload['request_ip'] = request_ip 44 | 45 | return encode(payload, client_secret, algorithm='HS256') 46 | 47 | @classmethod 48 | def create_url(cls, client, customer_id, redirect_url=None, request_ip=None, use_bc_time=False, channel_id:int=1): 49 | secure_url = client.Store.all()['secure_url'] 50 | iat_time = None 51 | if use_bc_time: 52 | iat_time = client.Time.all()['time'] 53 | login_token = cls.create(client, customer_id, redirect_url, 54 | request_ip, iat_time=iat_time, channel_id=channel_id) 55 | else: 56 | login_token = cls.create(client, customer_id, redirect_url, request_ip, channel_id=channel_id) 57 | return '%s/login/token/%s' % (secure_url, login_token) 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.22.0 2 | * Update dependencies and drop support for python <3.5 3 | * Add method for decoding signed_payload_jwt 4 | * Add example for V3 API to readme 5 | 6 | ### 0.20.1 7 | * [karen-white] Updated allowable methods on ProductVideos 8 | 9 | ### 0.20.0 10 | * [bookernath] Automatically handle rate limiting via optional parameters 11 | 12 | ### 0.19.1 13 | * [bookernath] Bump requests to 2.20.0 14 | 15 | ### 0.19.0 16 | * [bookernath] Add counting for Web Pages 17 | * [bookernath] Add CRUD of Blog Posts 18 | 19 | ### 0.18.2 20 | * [Anmol-Gulati] Do not use mutable objects as default arguments 21 | 22 | ### 0.18.1 23 | * [bookernath] Option to use BC server time to synchronize BC login API 24 | * [bookernath] Fix app installation flow 25 | 26 | ### 0.18.0 27 | * [surbas] New method for automatically paginating resources 28 | 29 | ### 0.17.3 30 | * [bookernath] Remove streql dependency 31 | * [bookernath] Fix redirect_to parameter for Customer Login API 32 | 33 | ### 0.17.2 34 | * [bookernath] Support for new rate limiting headers (OAuth connections only) 35 | 36 | ### 0.17.1 37 | * [bookernath] Gzip support 38 | * [neybar] Filters on SKU resource 39 | 40 | ### 0.17.0 41 | * [bc-croh92] PEP-470 compliance in setup.py 42 | * [verygoodsoftwarenotvirus] Fixed a typo in readme 43 | * [jbub] Add missing order taxes subresource 44 | 45 | ### 0.16.0 46 | * [bookernath] Add support for web pages 47 | 48 | ### 0.15.0 49 | * [bookernath] Add order message support 50 | * [bookernath] Add support for customer login tokens 51 | 52 | ### 0.14.0 53 | * [bookernath] Add support for currencies 54 | 55 | ### 0.13.0 56 | * [bookernath] Add support for product reviews CRUD 57 | 58 | ### 0.12.0 59 | * [mattolson] Update badges for pypip.in 60 | * [bc-bfenton] Updating code for pep8 compliance 61 | * [bookernath] Add gift certs and banners as resources 62 | * [bookernath] Fix bug with path counting 63 | 64 | ### 0.11.0 65 | 66 | * [jtallieu] Add support for Google product mappings endpoint 67 | * [mattolson] Add counts for all resources and subresources that support it 68 | * [mattolson] Refactor to allow override of paths; Extract delete_all into a separate class 69 | * [sebaacuna] Add support for Product count 70 | * [mattolson] Allow override of auth endpoint with environment var 71 | * [mattolson] Allow override of API endpoint with environment var 72 | * [hockeybuggy] Cleanup whitespace 73 | * [Alir3z4, hockeybuggy] Add Webhooks resource 74 | * [skyler] Update README 75 | * [sgerrand] Update README 76 | 77 | 78 | ### 0.10.2 79 | 80 | * The beginning of time (at least as far as this change log is concerned). 81 | -------------------------------------------------------------------------------- /bigcommerce/resources/orders.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Orders(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'orders' 8 | 9 | def coupons(self, id=None): 10 | if id: 11 | return OrderCoupons.get(self.id, id, connection=self._connection) 12 | else: 13 | return OrderCoupons.all(self.id, connection=self._connection) 14 | 15 | def products(self, id=None): 16 | if id: 17 | return OrderProducts.get(self.id, id, connection=self._connection) 18 | else: 19 | return OrderProducts.all(self.id, connection=self._connection) 20 | 21 | def shipments(self, id=None): 22 | if id: 23 | return OrderShipments.get(self.id, id, connection=self._connection) 24 | else: 25 | return OrderShipments.all(self.id, connection=self._connection) 26 | 27 | def shipping_addresses(self, id=None): 28 | if id: 29 | return OrderShippingAddresses.get(self.id, id, connection=self._connection) 30 | else: 31 | return OrderShippingAddresses.all(self.id, connection=self._connection) 32 | 33 | def messages(self, id=None): 34 | if id: 35 | return OrderMessages.get(self.id, id, connection=self._connection) 36 | else: 37 | return OrderMessages.all(self.id, connection=self._connection) 38 | 39 | def taxes(self, id=None): 40 | if id: 41 | return OrderTaxes.get(self.id, id, connection=self._connection) 42 | else: 43 | return OrderTaxes.all(self.id, connection=self._connection) 44 | 45 | 46 | class OrderCoupons(ListableApiSubResource): 47 | resource_name = 'coupons' 48 | parent_resource = 'orders' 49 | parent_key = 'order_id' 50 | 51 | 52 | class OrderProducts(ListableApiSubResource, CountableApiSubResource): 53 | resource_name = 'products' 54 | parent_resource = 'orders' 55 | parent_key = 'order_id' 56 | count_resource = 'orders/products' 57 | 58 | 59 | class OrderShipments(ListableApiSubResource, CreateableApiSubResource, 60 | UpdateableApiSubResource, DeleteableApiSubResource, 61 | CollectionDeleteableApiSubResource, CountableApiSubResource): 62 | resource_name = 'shipments' 63 | parent_resource = 'orders' 64 | parent_key = 'order_id' 65 | count_resource = 'orders/shipments' 66 | 67 | 68 | class OrderShippingAddresses(ListableApiSubResource, CountableApiSubResource): 69 | resource_name = 'shipping_addresses' 70 | parent_resource = 'orders' 71 | parent_key = 'order_id' 72 | count_resource = 'orders/shipping_addresses' 73 | 74 | 75 | class OrderMessages(ListableApiSubResource): 76 | resource_name = 'messages' 77 | parent_resource = 'orders' 78 | parent_key = 'order_id' 79 | 80 | 81 | class OrderTaxes(ListableApiSubResource): 82 | resource_name = 'taxes' 83 | parent_resource = 'orders' 84 | parent_key = 'order_id' 85 | -------------------------------------------------------------------------------- /bigcommerce/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from bigcommerce import connection 4 | from bigcommerce.resources import * # Needed for ApiResourceWrapper dynamic loading 5 | 6 | 7 | class BigcommerceApi(object): 8 | def __init__(self, host=None, basic_auth=None, 9 | client_id=None, store_hash=None, access_token=None, rate_limiting_management=None): 10 | self.api_service = os.getenv('BC_API_ENDPOINT', 'api.bigcommerce.com') 11 | self.auth_service = os.getenv('BC_AUTH_SERVICE', 'login.bigcommerce.com') 12 | 13 | if host and basic_auth: 14 | self.connection = connection.Connection(host, basic_auth) 15 | elif client_id and store_hash: 16 | self.connection = connection.OAuthConnection(client_id, store_hash, access_token, self.api_service, 17 | rate_limiting_management=rate_limiting_management) 18 | else: 19 | raise Exception("Must provide either (client_id and store_hash) or (host and basic_auth)") 20 | 21 | def oauth_fetch_token(self, client_secret, code, context, scope, redirect_uri): 22 | if isinstance(self.connection, connection.OAuthConnection): 23 | token_url = 'https://%s/oauth2/token' % self.auth_service 24 | return self.connection.fetch_token(client_secret, code, context, scope, redirect_uri, token_url) 25 | 26 | @classmethod 27 | def oauth_verify_payload(cls, signed_payload, client_secret): 28 | return connection.OAuthConnection.verify_payload(signed_payload, client_secret) 29 | 30 | @classmethod 31 | def oauth_verify_payload_jwt(cls, signed_payload, client_secret, client_id): 32 | return connection.OAuthConnection.verify_payload_jwt(signed_payload, client_secret, client_id) 33 | 34 | def __getattr__(self, item): 35 | return ApiResourceWrapper(item, self) 36 | 37 | 38 | class ApiResourceWrapper(object): 39 | """ 40 | Provides dot access to each of the API resources 41 | while proxying the connection parameter so that 42 | the user does not need to know it exists 43 | """ 44 | def __init__(self, resource_class, api): 45 | """ 46 | :param resource_class: String or Class to proxy 47 | :param api: API whose connection we want to use 48 | :return: A wrapper instance 49 | """ 50 | if isinstance(resource_class, str): 51 | self.resource_class = self.str_to_class(resource_class) 52 | else: 53 | self.resource_class = resource_class 54 | self.connection = api.connection 55 | 56 | def __getattr__(self, item): 57 | """ 58 | Proxies access to all methods on the resource class, 59 | injecting the connection parameter before any 60 | other arguments 61 | 62 | TODO: Distinguish between methods and attributes 63 | on the resource class? 64 | """ 65 | return lambda *args, **kwargs: (getattr(self.resource_class, item))(*args, connection=self.connection, **kwargs) 66 | 67 | @classmethod 68 | def str_to_class(cls, str): 69 | """ 70 | Transforms a string class name into a class object 71 | Assumes that the class is already loaded. 72 | """ 73 | return getattr(sys.modules[__name__], str) 74 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import bigcommerce.api 5 | from bigcommerce.connection import Connection, OAuthConnection 6 | from bigcommerce.resources import ApiResource 7 | from mock import MagicMock, patch, Mock 8 | 9 | class TestBigcommerceApi(unittest.TestCase): 10 | """ Test API client creation and helpers""" 11 | 12 | def test_create_basic(self): 13 | api = bigcommerce.api.BigcommerceApi(host='store.mybigcommerce.com', basic_auth=('admin', 'abcdef')) 14 | self.assertIsInstance(api.connection, Connection) 15 | self.assertNotIsInstance(api.connection, OAuthConnection) 16 | 17 | def test_create_oauth(self): 18 | api = bigcommerce.api.BigcommerceApi(client_id='123456', store_hash='abcdef', access_token='123abc') 19 | self.assertIsInstance(api.connection, OAuthConnection) 20 | 21 | def test_default_api_endpoint(self): 22 | api = bigcommerce.api.BigcommerceApi(client_id='123456', store_hash='abcdef', access_token='123abc') 23 | self.assertEqual(api.api_service, 'api.bigcommerce.com') 24 | 25 | def test_alternate_api_endpoint_from_env(self): 26 | os.environ['BC_API_ENDPOINT'] = 'foobar.com' 27 | api = bigcommerce.api.BigcommerceApi(client_id='123456', store_hash='abcdef', access_token='123abc') 28 | self.assertEqual(api.api_service, 'foobar.com') 29 | del os.environ['BC_API_ENDPOINT'] 30 | 31 | def test_default_auth_endpoint(self): 32 | api = bigcommerce.api.BigcommerceApi(client_id='123456', store_hash='abcdef', access_token='123abc') 33 | self.assertEqual(api.auth_service, 'login.bigcommerce.com') 34 | 35 | def test_alternate_auth_endpoint_from_env(self): 36 | os.environ['BC_AUTH_SERVICE'] = 'foobar.com' 37 | api = bigcommerce.api.BigcommerceApi(client_id='123456', store_hash='abcdef', access_token='123abc') 38 | self.assertEqual(api.auth_service, 'foobar.com') 39 | del os.environ['BC_AUTH_SERVICE'] 40 | 41 | def test_create_incorrect_args(self): 42 | self.assertRaises(Exception, lambda: bigcommerce.api.BigcommerceApi(client_id='123', basic_auth=('admin', 'token'))) 43 | 44 | 45 | class TestApiResourceWrapper(unittest.TestCase): 46 | 47 | def test_create(self): 48 | api = MagicMock() 49 | api.connection = MagicMock() 50 | 51 | wrapper = bigcommerce.api.ApiResourceWrapper('ApiResource', api) 52 | self.assertEqual(api.connection, wrapper.connection) 53 | self.assertEqual(wrapper.resource_class, ApiResource) 54 | 55 | wrapper = bigcommerce.api.ApiResourceWrapper(ApiResource, api) 56 | self.assertEqual(wrapper.resource_class, ApiResource) 57 | 58 | def test_str_to_class(self): 59 | cls = bigcommerce.api.ApiResourceWrapper.str_to_class('ApiResource') 60 | self.assertEqual(cls, ApiResource) 61 | 62 | self.assertRaises(AttributeError, lambda: bigcommerce.api.ApiResourceWrapper.str_to_class('ApiResourceWhichDoesNotExist')) 63 | 64 | @patch.object(ApiResource, 'get') 65 | def test_get_attr(self, patcher): 66 | api = MagicMock() 67 | api.connection = MagicMock() 68 | 69 | result = {'id': 1} 70 | patcher.return_value = result 71 | 72 | wrapper = bigcommerce.api.ApiResourceWrapper('ApiResource', api) 73 | self.assertEqual(wrapper.get(1), result) 74 | patcher.assert_called_once_with(1, connection=api.connection) 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /bigcommerce/resources/products.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | class Products(ListableApiResource, CreateableApiResource, 5 | UpdateableApiResource, DeleteableApiResource, 6 | CollectionDeleteableApiResource, CountableApiResource): 7 | resource_name = 'products' 8 | 9 | def configurable_fields(self, id=None): 10 | if id: 11 | return ProductConfigurableFields.get(self.id, id, connection=self._connection) 12 | else: 13 | return ProductConfigurableFields.all(self.id, connection=self._connection) 14 | 15 | def custom_fields(self, id=None): 16 | if id: 17 | return ProductCustomFields.get(self.id, id, connection=self._connection) 18 | else: 19 | return ProductCustomFields.all(self.id, connection=self._connection) 20 | 21 | def discount_rules(self, id=None): 22 | if id: 23 | return ProductDiscountRules.get(self.id, id, connection=self._connection) 24 | else: 25 | return ProductDiscountRules.all(self.id, connection=self._connection) 26 | 27 | def images(self, id=None): 28 | if id: 29 | return ProductImages.get(self.id, id, connection=self._connection) 30 | else: 31 | return ProductImages.all(self.id, connection=self._connection) 32 | 33 | def options(self, id=None): 34 | if id: 35 | return ProductOptions.get(self.id, id, connection=self._connection) 36 | else: 37 | return ProductOptions.all(self.id, connection=self._connection) 38 | 39 | def reviews(self, id=None): 40 | if id: 41 | return ProductReviews.get(self.id, id, connection=self._connection) 42 | else: 43 | return ProductReviews.all(self.id, connection=self._connection) 44 | 45 | def rules(self, id=None): 46 | if id: 47 | return ProductRules.get(self.id, id, connection=self._connection) 48 | else: 49 | return ProductRules.all(self.id, connection=self._connection) 50 | 51 | def skus(self, id=None): 52 | if id: 53 | return ProductSkus.get(self.id, id, connection=self._connection) 54 | else: 55 | return ProductSkus.all(self.id, connection=self._connection) 56 | 57 | def videos(self, id=None): 58 | if id: 59 | return ProductVideos.get(self.id, id, connection=self._connection) 60 | else: 61 | return ProductVideos.all(self.id, connection=self._connection) 62 | 63 | def google_mappings(self): 64 | return GoogleProductSearchMappings.all(self.id, connection=self._connection) 65 | 66 | 67 | class ProductConfigurableFields(ListableApiSubResource, DeleteableApiSubResource, 68 | CollectionDeleteableApiSubResource, CountableApiSubResource): 69 | resource_name = 'configurable_fields' 70 | parent_resource = 'products' 71 | parent_key = 'product_id' 72 | count_resource = 'products/configurable_fields' 73 | 74 | 75 | class ProductCustomFields(ListableApiSubResource, CreateableApiSubResource, 76 | UpdateableApiSubResource, DeleteableApiSubResource, 77 | CollectionDeleteableApiSubResource, CountableApiSubResource): 78 | resource_name = 'custom_fields' 79 | parent_resource = 'products' 80 | parent_key = 'product_id' 81 | count_resource = 'products/custom_fields' 82 | 83 | 84 | class ProductDiscountRules(ListableApiSubResource, CreateableApiSubResource, 85 | UpdateableApiSubResource, DeleteableApiSubResource, 86 | CollectionDeleteableApiSubResource, CountableApiSubResource): 87 | resource_name = 'discount_rules' 88 | parent_resource = 'products' 89 | parent_key = 'product_id' 90 | count_resource = 'products/discount_rules' 91 | 92 | 93 | class ProductImages(ListableApiSubResource, CreateableApiSubResource, 94 | UpdateableApiSubResource, DeleteableApiSubResource, 95 | CollectionDeleteableApiSubResource, CountableApiSubResource): 96 | resource_name = 'images' 97 | parent_resource = 'products' 98 | parent_key = 'product_id' 99 | count_resource = 'products/images' 100 | 101 | 102 | class ProductOptions(ListableApiSubResource): 103 | resource_name = 'options' 104 | parent_resource = 'products' 105 | parent_key = 'product_id' 106 | 107 | 108 | class ProductReviews(ListableApiSubResource, CreateableApiSubResource, 109 | UpdateableApiSubResource, DeleteableApiSubResource, 110 | CollectionDeleteableApiSubResource, CountableApiSubResource): 111 | resource_name = 'reviews' 112 | parent_resource = 'products' 113 | parent_key = 'product_id' 114 | count_resource = 'products/reviews' 115 | 116 | 117 | class ProductRules(ListableApiSubResource, CreateableApiSubResource, 118 | UpdateableApiSubResource, DeleteableApiSubResource, 119 | CollectionDeleteableApiSubResource, CountableApiSubResource): 120 | resource_name = 'rules' 121 | parent_resource = 'products' 122 | parent_key = 'product_id' 123 | count_resource = 'products/rules' 124 | 125 | 126 | class ProductSkus(ListableApiSubResource, CreateableApiSubResource, 127 | UpdateableApiSubResource, DeleteableApiSubResource, 128 | CollectionDeleteableApiSubResource, CountableApiSubResource): 129 | resource_name = 'skus' 130 | parent_resource = 'products' 131 | parent_key = 'product_id' 132 | count_resource = 'products/skus' 133 | 134 | 135 | class ProductVideos(ListableApiSubResource, CountableApiSubResource, 136 | CreateableApiSubResource, DeleteableApiSubResource, 137 | CollectionDeleteableApiSubResource): 138 | resource_name = 'videos' 139 | parent_resource = 'products' 140 | parent_key = 'product_id' 141 | count_resource = 'products/videos' 142 | 143 | 144 | class GoogleProductSearchMappings(ListableApiSubResource): 145 | resource_name = 'googleproductsearch' 146 | parent_resource = 'products' 147 | parent_key = 'product_id' 148 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Bigcommerce API Python Client 2 | ================================== 3 | 4 | |Build Status| |Package Version| 5 | 6 | Wrapper over the ``requests`` library for communicating with the Bigcommerce v2 API. 7 | 8 | Install with ``pip install bigcommerce`` or ``easy_install bigcommerce``. Tested with 9 | python 3.7-3.9, and only requires ``requests`` and ``pyjwt``. 10 | 11 | Usage 12 | ----- 13 | 14 | Connecting 15 | ~~~~~~~~~~ 16 | 17 | .. code:: python 18 | 19 | import bigcommerce 20 | 21 | # Public apps (OAuth) 22 | # Access_token is optional, if you don't have one you can use oauth_fetch_token (see below) 23 | api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='') 24 | 25 | # Private apps (Basic Auth) 26 | api = bigcommerce.api.BigcommerceApi(host='store.mybigcommerce.com', basic_auth=('username', 'api token')) 27 | 28 | ``BigcommerceApi`` also provides two helper methods for connection with OAuth2: 29 | 30 | - ``api.oauth_fetch_token(client_secret, code, context, scope, redirect_uri)`` 31 | -- fetches and returns an access token for your application. As a 32 | side effect, configures ``api`` to be ready for use. 33 | 34 | - ``BigcommerceApi.oauth_verify_payload(signed_payload, client_secret)`` 35 | -- Returns user data from a signed payload. 36 | 37 | Accessing and objects 38 | ~~~~~~~~~~~~~~~~~~~~~ 39 | 40 | The ``api`` object provides access to each API resource, each of which 41 | provides CRUD operations, depending on capabilities of the resource: 42 | 43 | .. code:: python 44 | 45 | api.Products.all() # GET /products (returns only a single page of products as a list) 46 | api.Products.iterall() # GET /products (autopaging generator that yields all 47 | # products from all pages product by product.) 48 | api.Products.get(1) # GET /products/1 49 | api.Products.create(name='', type='', ...) # POST /products 50 | api.Products.get(1).update(price='199.90') # PUT /products/1 51 | api.Products.delete_all() # DELETE /products 52 | api.Products.get(1).delete() # DELETE /products/1 53 | api.Products.count() # GET /products/count 54 | 55 | The client provides full access to subresources, both as independent 56 | resources: 57 | 58 | :: 59 | 60 | api.ProductOptions.get(1) # GET /products/1/options 61 | api.ProductOptions.get(1, 2) # GET /products/1/options/2 62 | 63 | And as helper methods on the parent resource: 64 | 65 | :: 66 | 67 | api.Products.get(1).options() # GET /products/1/options 68 | api.Products.get(1).options(1) # GET /products/1/options/1 69 | 70 | These subresources implement CRUD methods in exactly the same way as 71 | regular resources: 72 | 73 | :: 74 | 75 | api.Products.get(1).options(1).delete() 76 | 77 | Filters 78 | ~~~~~~~ 79 | 80 | Filters can be applied to ``all`` methods as keyword arguments: 81 | 82 | .. code:: python 83 | 84 | customer = api.Customers.all(first_name='John', last_name='Smith')[0] 85 | orders = api.Orders.all(customer_id=customer.id) 86 | 87 | Error handling 88 | ~~~~~~~~~~~~~~ 89 | 90 | Minimal validation of data is performed by the client, instead deferring 91 | this to the server. A ``HttpException`` will be raised for any unusual 92 | status code: 93 | 94 | - 3xx status code: ``RedirectionException`` 95 | - 4xx status code: ``ClientRequestException`` 96 | - 5xx status code: ``ServerException`` 97 | 98 | The low level API 99 | ~~~~~~~~~~~~~~~~~ 100 | 101 | The high level API provided by ``bigcommerce.api.BigcommerceApi`` is a 102 | wrapper around a lower level api in ``bigcommerce.connection``. This can 103 | be accessed through ``api.connection``, and provides helper methods for 104 | get/post/put/delete operations. 105 | 106 | Accessing V3 API endpoints 107 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 108 | Although this library currently only supports high-level modeling for V2 API endpoints, 109 | it can be used to access V3 APIs using the OAuthConnection object: 110 | 111 | :: 112 | 113 | v3client = bigcommerce.connection.OAuthConnection(client_id=client_id, 114 | store_hash=store_hash, 115 | access_token=access_token, 116 | api_path='/stores/{}/v3/{}') 117 | v3client.get('/catalog/products', include_fields='name,sku', limit=5, page=1) 118 | 119 | Accessing GraphQL Admin API 120 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 121 | There is a basic GraphQL client which allows you to submit GraphQL queries to the GraphQL Admin API. 122 | 123 | :: 124 | 125 | gql = bigcommerce.connection.GraphQLConnection( 126 | client_id=client_id, 127 | store_hash=store_hash, 128 | access_token=access_token 129 | ) 130 | # Make a basic query 131 | time_query_result = gql.query(""" 132 | query { 133 | system { 134 | time 135 | } 136 | } 137 | """) 138 | # Fetch the schema 139 | schema = gql.introspection_query() 140 | 141 | 142 | Managing Rate Limits 143 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 144 | 145 | You can optionally pass a ``rate_limiting_management`` object into ``bigcommerce.api.BigcommerceApi`` or ``bigcommerce.connection.OAuthConnection`` for automatic rate limiting management, ex: 146 | 147 | .. code:: python 148 | 149 | import bigcommerce 150 | 151 | api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='' 152 | rate_limiting_management= {'min_requests_remaining':2, 153 | 'wait':True, 154 | 'callback_function':None}) 155 | 156 | ``min_requests_remaining`` will determine the number of requests remaining in the rate limiting window which will invoke the management function 157 | 158 | ``wait`` determines whether or not we should automatically sleep until the end of the window 159 | 160 | ``callback_function`` is a function to run when the rate limiting management function fires. It will be invoked *after* the wait, if enabled. 161 | 162 | ``callback_args`` is an optional parameter which is a dictionary passed as an argument to the callback function. 163 | 164 | For simple applications which run API requests in serial (and aren't interacting with many different stores, or use a separate worker for each store) the simple sleep function may work well enough for most purposes. For more complex applications that may be parallelizing API requests on a given store, it's adviseable to write your own callback function for handling the rate limiting, use a ``min_requests_remaining`` higher than your concurrency, and not use the default wait function. 165 | 166 | Further documentation 167 | --------------------- 168 | 169 | Full documentation of the API is available on the Bigcommerce 170 | `Developer Portal `__ 171 | 172 | To do 173 | ----- 174 | 175 | - Automatic enumeration of multiple page responses for subresources. 176 | 177 | .. |Build Status| image:: https://api.travis-ci.org/bigcommerce/bigcommerce-api-python.svg?branch=master 178 | :target: https://travis-ci.org/bigcommerce/bigcommerce-api-python 179 | .. |Package Version| image:: https://badge.fury.io/py/bigcommerce.svg 180 | :target: https://pypi.python.org/pypi/bigcommerce 181 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from bigcommerce.connection import Connection, OAuthConnection 4 | from bigcommerce.exception import ServerException, ClientRequestException, RedirectionException 5 | from mock import patch, MagicMock 6 | 7 | 8 | class TestConnection(unittest.TestCase): 9 | def test_create(self): 10 | connection = Connection(host='store.mybigcommerce.com', auth=('user', 'abcdef')) 11 | self.assertTupleEqual(connection._session.auth, ('user', 'abcdef')) 12 | 13 | def test_full_path(self): 14 | connection = Connection(host='store.mybigcommerce.com', auth=('user', 'abcdef')) 15 | self.assertEqual(connection.full_path('time'), 'https://store.mybigcommerce.com/api/v2/time') 16 | 17 | def test_run_method(self): 18 | connection = Connection(host='store.mybigcommerce.com', auth=('user', 'abcdef')) 19 | connection._session.request = MagicMock() 20 | 21 | # Call with nothing 22 | connection._run_method('GET', '') 23 | connection._session.request.assert_called_once_with('GET', 'https://store.mybigcommerce.com/api/v2/', 24 | data=None, timeout=7.0, headers={}) 25 | connection._session.request.reset_mock() 26 | 27 | # A simple request 28 | connection._run_method('GET', 'time') 29 | connection._session.request.assert_called_once_with('GET', 'https://store.mybigcommerce.com/api/v2/time', 30 | data=None, timeout=7.0, headers={}) 31 | connection._session.request.reset_mock() 32 | 33 | # A request with data 34 | data = { 35 | 'name': 'Shirt', 36 | 'price': 25.00 37 | } 38 | 39 | connection._run_method('POST', '/products', data) 40 | 41 | connection._session.request.assert_called_once_with('POST', 'https://store.mybigcommerce.com/api/v2/products', 42 | data=json.dumps(data), timeout=7.0, 43 | headers={'Content-Type': 'application/json'}) 44 | connection._session.request.reset_mock() 45 | 46 | # A request with filters 47 | connection._run_method('GET', '/orders', query={'limit': 50}) 48 | connection._session.request.assert_called_once_with('GET', 49 | 'https://store.mybigcommerce.com/api/v2/orders?limit=50', 50 | data=None, timeout=7.0, headers={}) 51 | connection._session.request.reset_mock() 52 | 53 | def test_handle_response(self): 54 | connection = Connection('store.mybigcommerce.com', ('user', 'abcdef')) 55 | # A normal, 200-ok response 56 | data = { 57 | 'name': 'Shirt' 58 | } 59 | res = MagicMock() 60 | res.headers = {'Content-Type': 'application/json'} 61 | res.status_code = 200 62 | res.content = json.dumps(data) 63 | res.json.return_value = data 64 | self.assertEqual(connection._handle_response('products/1', res), data) 65 | 66 | res.status_code = 500 67 | self.assertRaisesHttpException(ServerException, 68 | lambda: connection._handle_response('products/1', res), 69 | # Test all of the properties of a HttpException 70 | 500, 71 | {'Content-Type': 'application/json'}, 72 | json.dumps(data)) 73 | 74 | res.status_code = 404 75 | self.assertRaisesHttpException(ClientRequestException, 76 | lambda: connection._handle_response('products/1', res), 404) 77 | 78 | res.status_code = 301 79 | self.assertRaisesHttpException(RedirectionException, 80 | lambda: connection._handle_response('products/1', res), 301) 81 | 82 | def assertRaisesHttpException(self, exec_class, callable, status_code=None, headers=None, content=None): 83 | try: 84 | callable() 85 | self.assertFail() 86 | except exec_class as e: 87 | if status_code: 88 | self.assertEqual(status_code, e.status_code) 89 | if headers: 90 | self.assertDictEqual(headers, e.headers) 91 | if content: 92 | self.assertEqual(content, e.content) 93 | 94 | 95 | class TestOAuthConnection(unittest.TestCase): 96 | def test_full_path(self): 97 | connection = OAuthConnection(client_id='123', store_hash='abcdef') 98 | self.assertEqual(connection.full_path('time'), 'https://api.bigcommerce.com/stores/abcdef/v2/time') 99 | 100 | def test_alternate_api_endpoint(self): 101 | connection = OAuthConnection(client_id='123', store_hash='abcdef', host='barbaz.com') 102 | self.assertEqual(connection.full_path('time'), 'https://barbaz.com/stores/abcdef/v2/time') 103 | 104 | def test_verify_payload(self): 105 | """Decode and verify signed payload.""" 106 | payload = "eyJ1c2VyIjp7ImlkIjo3MiwiZW1haWwiOiJqYWNraWUuaHV5bmh" \ 107 | "AYmlnY29tbWVyY2UuY29tIn0sInN0b3JlX2hhc2giOiJsY3R2aD" \ 108 | "V3bSIsInRpbWVzdGFtcCI6MTM4OTA1MDMyNy42NTc5NjI2fQ==." \ 109 | "ZTViYzAzNTM2MGFjM2M2YTVkZjFmNzFlYTk4NTY1ODZiMzkxODZmZDExZTdjZGFmOGEzN2E3YTEzNGQ0MmExYw==" 110 | client_secret = 'ntb1kcxa1do55wf0h25ps7h94fnsoi6' 111 | user_data = OAuthConnection.verify_payload(payload, client_secret) 112 | self.assertTrue(user_data) # otherwise verification has failed 113 | self.assertEqual(user_data['user']['id'], 72) 114 | self.assertEqual(user_data['user']['email'], "jackie.huynh@bigcommerce.com") 115 | 116 | # Try again with a fake payload 117 | payload = "notevenreal7ImlkIjo3MiwiZW1haWwiOiJqYWNraWUuaHV5bmh" \ 118 | "AYmlnY29tbWVyY2UuY29tIn0sInN0b3JlX2hhc2giOiJsY3R2aD" \ 119 | "V3bSIsInRpbWVzdGFtcCI6MTM4OTA1MDMyNy42NTc5NjI2fQ==." \ 120 | "quitefakeTM2MGFjM2M2YTVkZjFmNzFlYTk4NTY1ODZiMzkxODZmZDExZTdjZGFmOGEzN2E3YTEzNGQ0MmExYw==" 121 | 122 | user_data = OAuthConnection.verify_payload(payload, client_secret) 123 | self.assertFalse(user_data) 124 | 125 | def test_fetch_token(self): 126 | client_id = 'abc123' 127 | client_secret = '123abc' 128 | code = 'hellosecret' 129 | context = 'stores/abc' 130 | scope = 'store_v2_products' 131 | redirect_uri = 'http://localhost/callback' 132 | result = {'access_token': '12345abcdef'} 133 | 134 | connection = OAuthConnection(client_id, store_hash='abc') 135 | connection.post = MagicMock() 136 | connection.post.return_value = result 137 | 138 | res = connection.fetch_token(client_secret, code, context, scope, redirect_uri) 139 | self.assertEqual(res, result) 140 | self.assertDictEqual(connection._session.headers, 141 | {'X-Auth-Client': 'abc123', 'X-Auth-Token': '12345abcdef', 142 | 'Accept': 'application/json', 'Accept-Encoding': 'gzip'}) 143 | connection.post.assert_called_once_with('https://login.bigcommerce.com/oauth2/token', 144 | { 145 | 'client_id': client_id, 146 | 'client_secret': client_secret, 147 | 'code': code, 148 | 'context': context, 149 | 'scope': scope, 150 | 'grant_type': 'authorization_code', 151 | 'redirect_uri': redirect_uri 152 | }, 153 | headers={'Content-Type': 'application/x-www-form-urlencoded'} 154 | ) 155 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from bigcommerce.resources import Mapping, Orders, ApiResource, OrderShipments, Products, CountryStates,\ 3 | OrderCoupons, Webhooks, GoogleProductSearchMappings 4 | from mock import MagicMock 5 | 6 | 7 | class TestMapping(unittest.TestCase): 8 | def test_init(self): 9 | result = { 10 | 'coupons': {'url': 'blah'}, 11 | 'id': 1 12 | } 13 | 14 | map = Orders(result) 15 | self.assertEqual(map.id, 1) 16 | self.assertEqual(map['id'], 1) 17 | 18 | self.assertNotIsInstance(map.coupons, dict) 19 | 20 | def test_str(self): 21 | map = Mapping({'id': 1, '_connection': MagicMock()}) 22 | self.assertEqual(str(map), str({'id': 1})) 23 | 24 | 25 | class TestApiResource(unittest.TestCase): 26 | def test_create_object(self): 27 | # Test with a single object 28 | result = {'id': 1} 29 | object = ApiResource._create_object(result, MagicMock()) 30 | self.assertEqual(object.id, 1) 31 | 32 | # Test with a list 33 | results = [{'id': 1}, {'id': 2}, {'id': 3}] 34 | objects = ApiResource._create_object(results, MagicMock) 35 | self.assertIsInstance(objects, list) 36 | for object in objects: 37 | self.assertIsNotNone(object.id) 38 | self.assertIsNotNone(object._connection) 39 | 40 | def test_get(self): 41 | connection = MagicMock() 42 | connection.make_request.return_value = {'id': 1} 43 | 44 | result = Orders.get(1, connection) 45 | self.assertIsInstance(result, Orders) 46 | self.assertEqual(result.id, 1) 47 | 48 | connection.make_request.assert_called_once_with('GET', 'orders/1', None, {}, None) 49 | 50 | 51 | class TestApiSubResource(unittest.TestCase): 52 | def test_get(self): 53 | connection = MagicMock() 54 | connection.make_request.return_value = {'id': 2} 55 | 56 | result = OrderCoupons.get(1, 2, connection) 57 | self.assertIsInstance(result, OrderCoupons) 58 | self.assertEqual(result.id, 2) 59 | 60 | connection.make_request.assert_called_once_with('GET', 'orders/1/coupons/2', None, {}, None) 61 | 62 | def test_parent_id(self): 63 | coupon = OrderCoupons({'id': 2, 'order_id': 1}) 64 | self.assertEqual(coupon.parent_id(), 1) 65 | 66 | 67 | class TestCreateableApiResource(unittest.TestCase): 68 | def test_create(self): 69 | connection = MagicMock() 70 | connection.make_request.return_value = {'id': 1} 71 | 72 | result = Orders.create(connection, name="Hello") 73 | self.assertIsInstance(result, Orders) 74 | self.assertEqual(result.id, 1) 75 | connection.make_request.assert_called_once_with('POST', 'orders', {'name': 'Hello'}, None, None) 76 | 77 | 78 | class TestCreateableApiSubResource(unittest.TestCase): 79 | def test_create(self): 80 | connection = MagicMock() 81 | connection.make_request.return_value = {'id': 2} 82 | 83 | result = OrderShipments.create(1, connection, name="Hello") 84 | self.assertIsInstance(result, OrderShipments) 85 | self.assertEqual(result.id, 2) 86 | connection.make_request.assert_called_once_with('POST', 'orders/1/shipments', {'name': 'Hello'}, None, None) 87 | 88 | 89 | class TestListableApiResource(unittest.TestCase): 90 | def test_all(self): 91 | connection = MagicMock() 92 | connection.make_request.return_value = [{'id': 1}, {'id': 2}, {'id': 2}] 93 | 94 | result = Orders.all(connection, limit=3) 95 | self.assertEqual(len(list(result)), 3) 96 | connection.make_request.assert_called_once_with('GET', 'orders', None, {'limit': 3}, None) 97 | 98 | 99 | class TestListableApiSubResource(unittest.TestCase): 100 | def test_all(self): 101 | connection = MagicMock() 102 | connection.make_request.return_value = [{'id': 1}, {'id': 2}] 103 | 104 | result = OrderCoupons.all(1, connection, limit=2) 105 | self.assertEqual(len(result), 2) 106 | connection.make_request.assert_called_once_with('GET', 'orders/1/coupons', None, {'limit': 2}, None) 107 | 108 | def test_google_mappings(self): 109 | connection = MagicMock() 110 | connection.make_request.return_value = [{'id': 1}, {'id': 2}] 111 | 112 | result = GoogleProductSearchMappings.all(1, connection, limit=2) 113 | self.assertEqual(len(result), 2) 114 | connection.make_request.assert_called_once_with('GET', 'products/1/googleproductsearch', None, {'limit': 2}, None) 115 | 116 | 117 | class TestUpdateableApiResource(unittest.TestCase): 118 | def test_update(self): 119 | connection = MagicMock() 120 | connection.make_request.return_value = {'id': 1} 121 | 122 | order = Orders({'id': 1}, _connection=connection) 123 | new_order = order.update(name='order') 124 | self.assertIsInstance(new_order, Orders) 125 | 126 | connection.make_request.assert_called_once_with('PUT', 'orders/1', {'name': 'order'}, None, None) 127 | 128 | 129 | class TestUpdateableApiSubResource(unittest.TestCase): 130 | def test_update(self): 131 | connection = MagicMock() 132 | connection.make_request.return_value = {'id': 1} 133 | 134 | order = OrderShipments({'id': 1, 'order_id': 2}, _connection=connection) 135 | new_order = order.update(tracking_number='1234') 136 | self.assertIsInstance(new_order, OrderShipments) 137 | 138 | connection.make_request.assert_called_once_with('PUT', 'orders/2/shipments/1', {'tracking_number': '1234'}, 139 | None, None) 140 | 141 | 142 | class TestDeleteableApiResource(unittest.TestCase): 143 | def test_delete_all(self): 144 | connection = MagicMock() 145 | connection.make_request.return_value = {} 146 | 147 | self.assertEqual(Orders.delete_all(connection), {}) 148 | 149 | connection.make_request.assert_called_once_with('DELETE', 'orders', None, None, None) 150 | 151 | def test_delete(self): 152 | connection = MagicMock() 153 | connection.make_request.return_value = {} 154 | 155 | order = Orders({'id': 1}, _connection=connection) 156 | 157 | self.assertEqual(order.delete(), {}) 158 | 159 | connection.make_request.assert_called_once_with('DELETE', 'orders/1', None, None, None) 160 | 161 | 162 | class TestDeleteableApiSubResource(unittest.TestCase): 163 | def test_delete_all(self): 164 | connection = MagicMock() 165 | connection.make_request.return_value = {} 166 | 167 | self.assertEqual(OrderShipments.delete_all(1, connection=connection), {}) 168 | 169 | connection.make_request.assert_called_once_with('DELETE', 'orders/1/shipments', None, None, None) 170 | 171 | def test_delete(self): 172 | connection = MagicMock() 173 | connection.make_request.return_value = {} 174 | 175 | shipment = OrderShipments({'id': 1, 'order_id': 2, '_connection': connection}) 176 | self.assertEqual(shipment.delete(), {}) 177 | 178 | connection.make_request.assert_called_once_with('DELETE', 'orders/2/shipments/1', None, None, None) 179 | 180 | 181 | class TestCountableApiResource(unittest.TestCase): 182 | def test_count(self): 183 | connection = MagicMock() 184 | connection.make_request.return_value = {'count': 2} 185 | 186 | self.assertEqual(Products.count(connection, is_visible=True), 2) 187 | connection.make_request.assert_called_once_with('GET', 'products/count', None, {'is_visible': True}, None) 188 | 189 | 190 | class TestCountableApiSubResource(unittest.TestCase): 191 | def test_count(self): 192 | connection = MagicMock() 193 | connection.make_request.return_value = {'count': 2} 194 | 195 | self.assertEqual(CountryStates.count(1, connection=connection, is_visible=True), 2) 196 | connection.make_request.assert_called_once_with('GET', 'countries/1/states/count', 197 | None, {'is_visible': True}, None) 198 | 199 | def test_count_with_custom_count_path(self): 200 | connection = MagicMock() 201 | connection.make_request.return_value = {'count': 2} 202 | 203 | self.assertEqual(OrderShipments.count(connection=connection, is_visible=True), 2) 204 | connection.make_request.assert_called_once_with('GET', 'orders/shipments/count', 205 | None, {'is_visible': True}, None) 206 | -------------------------------------------------------------------------------- /bigcommerce/resources/base.py: -------------------------------------------------------------------------------- 1 | class Mapping(dict): 2 | """ 3 | Mapping 4 | 5 | provides '.' access to dictionary keys 6 | """ 7 | def __init__(self, mapping, *args, **kwargs): 8 | """ 9 | Create a new mapping. Filters the mapping argument 10 | to remove any elements that are already methods on the 11 | object. 12 | 13 | For example, Orders retains its `coupons` method, instead 14 | of being replaced by the dict describing the coupons endpoint 15 | """ 16 | 17 | mapping = mapping or {} 18 | 19 | filter_args = {k: mapping[k] for k in mapping if k not in dir(self)} 20 | self.__dict__ = self 21 | dict.__init__(self, filter_args, *args, **kwargs) 22 | 23 | def __str__(self): 24 | """ 25 | Display as a normal dict, but filter out underscored items first 26 | """ 27 | return str({k: self.__dict__[k] for k in self.__dict__ if not k.startswith("_")}) 28 | 29 | def __repr__(self): 30 | return "<%s at %s, %s>" % (type(self).__name__, hex(id(self)), str(self)) 31 | 32 | 33 | class ApiResource(Mapping): 34 | resource_name = "" # The identifier which describes this resource in urls 35 | 36 | @classmethod 37 | def _create_object(cls, response, connection=None): 38 | if response and not isinstance(response, dict): 39 | return [cls(obj, _connection=connection) for obj in response] 40 | else: 41 | return cls(response, _connection=connection) 42 | 43 | @classmethod 44 | def _make_request(cls, method, url, connection, data=None, params=None, headers=None): 45 | return connection.make_request(method, url, data, params, headers) 46 | 47 | @classmethod 48 | def _get_path(cls, id): 49 | return "%s/%s" % (cls.resource_name, id) 50 | 51 | @classmethod 52 | def get(cls, id, connection=None, **params): 53 | response = cls._make_request('GET', cls._get_path(id), connection, params=params) 54 | return cls._create_object(response, connection=connection) 55 | 56 | 57 | class ApiSubResource(ApiResource): 58 | parent_resource = "" 59 | parent_key = "" 60 | 61 | @classmethod 62 | def _get_path(cls, id, parentid): 63 | return "%s/%s/%s/%s" % (cls.parent_resource, parentid, cls.resource_name, id) 64 | 65 | @classmethod 66 | def get(cls, parentid, id, connection=None, **params): 67 | response = cls._make_request('GET', cls._get_path(id, parentid), connection, params=params) 68 | return cls._create_object(response, connection=connection) 69 | 70 | def parent_id(self): 71 | return self[self.parent_key] 72 | 73 | 74 | class CreateableApiResource(ApiResource): 75 | @classmethod 76 | def _create_path(cls): 77 | return cls.resource_name 78 | 79 | @classmethod 80 | def create(cls, connection=None, **params): 81 | response = cls._make_request('POST', cls._create_path(), connection, data=params) 82 | return cls._create_object(response, connection=connection) 83 | 84 | 85 | class CreateableApiSubResource(ApiSubResource): 86 | @classmethod 87 | def _create_path(cls, parentid): 88 | return "%s/%s/%s" % (cls.parent_resource, parentid, cls.resource_name) 89 | 90 | @classmethod 91 | def create(cls, parentid, connection=None, **params): 92 | response = cls._make_request('POST', cls._create_path(parentid), connection, data=params) 93 | return cls._create_object(response, connection=connection) 94 | 95 | 96 | class ListableApiResource(ApiResource): 97 | @classmethod 98 | def _get_all_path(cls): 99 | return cls.resource_name 100 | 101 | @classmethod 102 | def all(cls, connection=None, **params): 103 | """ 104 | Returns first page if no params passed in as a list. 105 | """ 106 | 107 | request = cls._make_request('GET', cls._get_all_path(), connection, params=params) 108 | return cls._create_object(request, connection=connection) 109 | 110 | @classmethod 111 | def iterall(cls, connection=None, **kwargs): 112 | """ 113 | Returns a autopaging generator that yields each object returned one by one. 114 | """ 115 | 116 | try: 117 | limit = kwargs['limit'] 118 | except KeyError: 119 | limit = None 120 | 121 | try: 122 | page = kwargs['page'] 123 | except KeyError: 124 | page = None 125 | 126 | def _all_responses(): 127 | page = 1 # one based 128 | params = kwargs.copy() 129 | 130 | while True: 131 | params.update(page=page, limit=250) 132 | rsp = cls._make_request('GET', cls._get_all_path(), connection, params=params) 133 | if rsp: 134 | yield rsp 135 | page += 1 136 | else: 137 | yield [] # needed for case where there is no objects 138 | break 139 | 140 | if not (limit or page): 141 | for rsp in _all_responses(): 142 | for obj in rsp: 143 | yield cls._create_object(obj, connection=connection) 144 | 145 | else: 146 | response = cls._make_request('GET', cls._get_all_path(), connection, params=kwargs) 147 | for obj in cls._create_object(response, connection=connection): 148 | yield obj 149 | 150 | 151 | class ListableApiSubResource(ApiSubResource): 152 | @classmethod 153 | def _get_all_path(cls, parentid=None): 154 | # Not all sub resources require a parent id. Eg: /api/v2/products/skus?sku= 155 | if (parentid): 156 | return "%s/%s/%s" % (cls.parent_resource, parentid, cls.resource_name) 157 | else: 158 | return "%s/%s" % (cls.parent_resource, cls.resource_name) 159 | 160 | @classmethod 161 | def all(cls, parentid=None, connection=None, **params): 162 | response = cls._make_request('GET', cls._get_all_path(parentid), connection, params=params) 163 | return cls._create_object(response, connection=connection) 164 | 165 | 166 | class UpdateableApiResource(ApiResource): 167 | def _update_path(self): 168 | return "%s/%s" % (self.resource_name, self.id) 169 | 170 | def update(self, **updates): 171 | response = self._make_request('PUT', self._update_path(), self._connection, data=updates) 172 | return self._create_object(response, connection=self._connection) 173 | 174 | 175 | class UpdateableApiSubResource(ApiSubResource): 176 | def _update_path(self): 177 | return "%s/%s/%s/%s" % (self.parent_resource, self.parent_id(), self.resource_name, self.id) 178 | 179 | def update(self, **updates): 180 | response = self._make_request('PUT', self._update_path(), self._connection, data=updates) 181 | return self._create_object(response, connection=self._connection) 182 | 183 | 184 | class DeleteableApiResource(ApiResource): 185 | def _delete_path(self): 186 | return "%s/%s" % (self.resource_name, self.id) 187 | 188 | def delete(self): 189 | return self._make_request('DELETE', self._delete_path(), self._connection) 190 | 191 | 192 | class DeleteableApiSubResource(ApiSubResource): 193 | def _delete_path(self): 194 | return "%s/%s/%s/%s" % (self.parent_resource, self.parent_id(), self.resource_name, self.id) 195 | 196 | def delete(self): 197 | return self._make_request('DELETE', self._delete_path(), self._connection) 198 | 199 | 200 | class CollectionDeleteableApiResource(ApiResource): 201 | @classmethod 202 | def _delete_all_path(cls): 203 | return cls.resource_name 204 | 205 | @classmethod 206 | def delete_all(cls, connection=None): 207 | return cls._make_request('DELETE', cls._delete_all_path(), connection) 208 | 209 | 210 | class CollectionDeleteableApiSubResource(ApiSubResource): 211 | @classmethod 212 | def _delete_all_path(cls, parentid): 213 | return "%s/%s/%s" % (cls.parent_resource, parentid, cls.resource_name) 214 | 215 | @classmethod 216 | def delete_all(cls, parentid, connection=None): 217 | return cls._make_request('DELETE', cls._delete_all_path(parentid), connection) 218 | 219 | 220 | class CountableApiResource(ApiResource): 221 | @classmethod 222 | def _count_path(cls): 223 | return "%s/count" % (cls.resource_name) 224 | 225 | @classmethod 226 | def count(cls, connection=None, **params): 227 | response = cls._make_request('GET', cls._count_path(), connection, params=params) 228 | return response['count'] 229 | 230 | 231 | class CountableApiSubResource(ApiSubResource): 232 | # Account for the fairly common case where the count path doesn't include the parent id 233 | count_resource = None 234 | 235 | @classmethod 236 | def _count_path(cls, parentid=None): 237 | if parentid is not None: 238 | return "%s/%s/%s/count" % (cls.parent_resource, parentid, cls.resource_name) 239 | elif cls.count_resource is not None: 240 | return "%s/count" % (cls.count_resource) 241 | else: 242 | # misconfiguration 243 | raise NotImplementedError('Count not implemented for this resource.') 244 | 245 | @classmethod 246 | def count(cls, parentid=None, connection=None, **params): 247 | response = cls._make_request('GET', cls._count_path(parentid), connection, params=params) 248 | return response['count'] 249 | -------------------------------------------------------------------------------- /bigcommerce/connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connection Module 3 | 4 | Handles put and get operations to the Bigcommerce REST API 5 | """ 6 | import base64 7 | import hashlib 8 | import hmac 9 | 10 | try: 11 | from urllib import urlencode 12 | except ImportError: 13 | from urllib.parse import urlencode 14 | 15 | import logging 16 | import requests 17 | import jwt 18 | 19 | from json import dumps, loads 20 | from math import ceil 21 | from time import sleep 22 | 23 | from bigcommerce.exception import * 24 | 25 | log = logging.getLogger("bigcommerce.connection") 26 | 27 | 28 | class Connection(object): 29 | """ 30 | Connection class manages the connection to the Bigcommerce REST API. 31 | """ 32 | 33 | def __init__(self, host, auth, api_path='/api/v2/{}'): 34 | self.host = host 35 | self.api_path = api_path 36 | 37 | self.timeout = 7.0 # need to catch timeout? 38 | 39 | log.info("API Host: %s/%s" % (self.host, self.api_path)) 40 | 41 | # set up the session 42 | self._session = requests.Session() 43 | self._session.auth = auth 44 | self._session.headers = {"Accept": "application/json"} 45 | 46 | self._last_response = None # for debugging 47 | 48 | def full_path(self, url): 49 | return "https://" + self.host + self.api_path.format(url) 50 | 51 | def _run_method(self, method, url, data=None, query=None, headers=None): 52 | if query is None: 53 | query = {} 54 | if headers is None: 55 | headers = {} 56 | 57 | # make full path if not given 58 | if url and url[:4] != "http": 59 | if url[0] == '/': # can call with /resource if you want 60 | url = url[1:] 61 | url = self.full_path(url) 62 | elif not url: # blank path 63 | url = self.full_path(url) 64 | 65 | qs = urlencode(query) 66 | if qs: 67 | qs = "?" + qs 68 | url += qs 69 | 70 | # mess with content 71 | if data: 72 | if not headers: # assume JSON 73 | data = dumps(data) 74 | headers = {'Content-Type': 'application/json'} 75 | if headers and 'Content-Type' not in headers: 76 | data = dumps(data) 77 | headers['Content-Type'] = 'application/json' 78 | log.debug("%s %s" % (method, url)) 79 | # make and send the request 80 | return self._session.request(method, url, data=data, timeout=self.timeout, headers=headers) 81 | 82 | # CRUD methods 83 | 84 | def get(self, resource="", rid=None, **query): 85 | """ 86 | Retrieves the resource with given id 'rid', or all resources of given type. 87 | Keep in mind that the API returns a list for any query that doesn't specify an ID, even when applying 88 | a limit=1 filter. 89 | Also be aware that float values tend to come back as strings ("2.0000" instead of 2.0) 90 | 91 | Keyword arguments can be parsed for filtering the query, for example: 92 | connection.get('products', limit=3, min_price=10.5) 93 | (see Bigcommerce resource documentation). 94 | """ 95 | if rid: 96 | if resource[-1] != '/': 97 | resource += '/' 98 | resource += str(rid) 99 | response = self._run_method('GET', resource, query=query) 100 | return self._handle_response(resource, response) 101 | 102 | def update(self, resource, rid, updates): 103 | """ 104 | Updates the resource with id 'rid' with the given updates dictionary. 105 | """ 106 | if resource[-1] != '/': 107 | resource += '/' 108 | resource += str(rid) 109 | return self.put(resource, data=updates) 110 | 111 | def create(self, resource, data): 112 | """ 113 | Create a resource with given data dictionary. 114 | """ 115 | return self.post(resource, data) 116 | 117 | def delete(self, resource, rid=None): # note that rid can't be 0 - problem? 118 | """ 119 | Deletes the resource with given id 'rid', or all resources of given type if rid is not supplied. 120 | """ 121 | if rid: 122 | if resource[-1] != '/': 123 | resource += '/' 124 | resource += str(rid) 125 | response = self._run_method('DELETE', resource) 126 | return self._handle_response(resource, response, suppress_empty=True) 127 | 128 | # Raw-er stuff 129 | 130 | def make_request(self, method, url, data=None, params=None, headers=None): 131 | response = self._run_method(method, url, data, params, headers) 132 | return self._handle_response(url, response) 133 | 134 | def put(self, url, data): 135 | """ 136 | Make a PUT request to save data. 137 | data should be a dictionary. 138 | """ 139 | response = self._run_method('PUT', url, data=data) 140 | log.debug("OUTPUT: %s" % response.content) 141 | return self._handle_response(url, response) 142 | 143 | def post(self, url, data, headers={}): 144 | """ 145 | POST request for creating new objects. 146 | data should be a dictionary. 147 | """ 148 | response = self._run_method('POST', url, data=data, headers=headers) 149 | return self._handle_response(url, response) 150 | 151 | def _handle_response(self, url, res, suppress_empty=True): 152 | """ 153 | Returns parsed JSON or raises an exception appropriately. 154 | """ 155 | self._last_response = res 156 | result = {} 157 | if res.status_code in (200, 201, 202): 158 | try: 159 | result = res.json() 160 | except Exception as e: # json might be invalid, or store might be down 161 | e.message += " (_handle_response failed to decode JSON: " + str(res.content) + ")" 162 | raise # TODO better exception 163 | elif res.status_code == 204 and not suppress_empty: 164 | raise EmptyResponseWarning("%d %s @ %s: %s" % (res.status_code, res.reason, url, res.content), res) 165 | elif res.status_code >= 500: 166 | raise ServerException("%d %s @ %s: %s" % (res.status_code, res.reason, url, res.content), res) 167 | elif res.status_code == 429: 168 | raise RateLimitingException("%d %s @ %s: %s" % (res.status_code, res.reason, url, res.content), res) 169 | elif res.status_code >= 400: 170 | raise ClientRequestException("%d %s @ %s: %s" % (res.status_code, res.reason, url, res.content), res) 171 | elif res.status_code >= 300: 172 | raise RedirectionException("%d %s @ %s: %s" % (res.status_code, res.reason, url, res.content), res) 173 | return result 174 | 175 | def __repr__(self): 176 | return "%s %s%s" % (self.__class__.__name__, self.host, self.api_path) 177 | 178 | 179 | class OAuthConnection(Connection): 180 | """ 181 | Class for making OAuth requests on the Bigcommerce v2 API 182 | 183 | Providing a value for access_token allows immediate access to resources within registered scope. 184 | Otherwise, you may use fetch_token with the code, context, and scope passed to your application's callback url 185 | to retrieve an access token. 186 | 187 | The verify_payload method is also provided for authenticating signed payloads passed to an application's load url. 188 | """ 189 | 190 | def __init__(self, client_id, store_hash, access_token=None, host='api.bigcommerce.com', 191 | api_path='/stores/{}/v2/{}', rate_limiting_management=None): 192 | self.client_id = client_id 193 | self.store_hash = store_hash 194 | self.host = host 195 | self.api_path = api_path 196 | self.timeout = 7.0 # can attach to session? 197 | self.rate_limiting_management = rate_limiting_management 198 | 199 | self._session = requests.Session() 200 | self._session.headers = {"Accept": "application/json", 201 | "Accept-Encoding": "gzip"} 202 | if access_token and store_hash: 203 | self._session.headers.update(self._oauth_headers(client_id, access_token)) 204 | 205 | self._last_response = None # for debugging 206 | 207 | self.rate_limit = {} 208 | 209 | def full_path(self, url): 210 | return "https://" + self.host + self.api_path.format(self.store_hash, url) 211 | 212 | @staticmethod 213 | def _oauth_headers(cid, atoken): 214 | return {'X-Auth-Client': cid, 215 | 'X-Auth-Token': atoken} 216 | 217 | @staticmethod 218 | def verify_payload(signed_payload, client_secret): 219 | """ 220 | Given a signed payload (usually passed as parameter in a GET request to the app's load URL) and a client secret, 221 | authenticates the payload and returns the user's data, or False on fail. 222 | 223 | Uses constant-time str comparison to prevent vulnerability to timing attacks. 224 | """ 225 | encoded_json, encoded_hmac = signed_payload.split('.') 226 | dc_json = base64.b64decode(encoded_json) 227 | signature = base64.b64decode(encoded_hmac) 228 | expected_sig = hmac.new(client_secret.encode(), base64.b64decode(encoded_json), hashlib.sha256).hexdigest() 229 | authorised = hmac.compare_digest(signature, expected_sig.encode()) 230 | return loads(dc_json.decode()) if authorised else False 231 | 232 | @staticmethod 233 | def verify_payload_jwt(signed_payload, client_secret, client_id): 234 | """ 235 | Given a signed payload JWT (usually passed as parameter in a GET request to the app's load URL) 236 | and a client secret, authenticates the payload and returns the user's data, or error on fail. 237 | """ 238 | return jwt.decode(signed_payload, 239 | client_secret, 240 | algorithms=["HS256", "HS512"], 241 | audience=client_id, 242 | options={ 243 | 'verify_iss': False 244 | }) 245 | 246 | def fetch_token(self, client_secret, code, context, scope, redirect_uri, 247 | token_url='https://login.bigcommerce.com/oauth2/token'): 248 | """ 249 | Fetches a token from given token_url, using given parameters, and sets up session headers for 250 | future requests. 251 | redirect_uri should be the same as your callback URL. 252 | code, context, and scope should be passed as parameters to your callback URL on app installation. 253 | 254 | Raises HttpException on failure (same as Connection methods). 255 | """ 256 | res = self.post(token_url, {'client_id': self.client_id, 257 | 'client_secret': client_secret, 258 | 'code': code, 259 | 'context': context, 260 | 'scope': scope, 261 | 'grant_type': 'authorization_code', 262 | 'redirect_uri': redirect_uri}, 263 | headers={'Content-Type': 'application/x-www-form-urlencoded'}) 264 | self._session.headers.update(self._oauth_headers(self.client_id, res['access_token'])) 265 | return res 266 | 267 | def _handle_response(self, url, res, suppress_empty=True): 268 | """ 269 | Adds rate limiting information on to the response object 270 | """ 271 | result = Connection._handle_response(self, url, res, suppress_empty) 272 | if 'X-Rate-Limit-Time-Reset-Ms' in res.headers: 273 | self.rate_limit = dict(ms_until_reset=int(res.headers['X-Rate-Limit-Time-Reset-Ms']), 274 | window_size_ms=int(res.headers['X-Rate-Limit-Time-Window-Ms']), 275 | requests_remaining=int(res.headers['X-Rate-Limit-Requests-Left']), 276 | requests_quota=int(res.headers['X-Rate-Limit-Requests-Quota'])) 277 | if self.rate_limiting_management: 278 | if self.rate_limiting_management['min_requests_remaining'] >= self.rate_limit['requests_remaining']: 279 | if self.rate_limiting_management['wait']: 280 | sleep(ceil(float(self.rate_limit['ms_until_reset']) / 1000)) 281 | if self.rate_limiting_management.get('callback_function'): 282 | callback = self.rate_limiting_management['callback_function'] 283 | args_dict = self.rate_limiting_management.get('callback_args') 284 | if args_dict: 285 | callback(args_dict) 286 | else: 287 | callback() 288 | 289 | return result 290 | 291 | 292 | class GraphQLConnection(OAuthConnection): 293 | def __init__(self, client_id, store_hash, access_token=None, host='api.bigcommerce.com', 294 | api_path='/stores/{}/graphql', rate_limiting_management=None): 295 | self.client_id = client_id 296 | self.store_hash = store_hash 297 | self.host = host 298 | self.api_path = api_path 299 | self.graphql_path = "https://" + self.host + self.api_path.format(self.store_hash) 300 | self.timeout = 7.0 # can attach to session? 301 | self.rate_limiting_management = rate_limiting_management 302 | 303 | self._session = requests.Session() 304 | self._session.headers = {"Accept": "application/json", 305 | "Accept-Encoding": "gzip"} 306 | if access_token and store_hash: 307 | self._session.headers.update(self._oauth_headers(client_id, access_token)) 308 | 309 | self._last_response = None # for debugging 310 | 311 | self.rate_limit = {} 312 | 313 | def query(self, query, variables={}): 314 | return self.post(self.graphql_path, dict(query=query, variables=variables)) 315 | 316 | def introspection_query(self): 317 | return self.query(""" 318 | fragment FullType on __Type { 319 | kind 320 | name 321 | fields(includeDeprecated: true) { 322 | name 323 | args { 324 | ...InputValue 325 | } 326 | type { 327 | ...TypeRef 328 | } 329 | isDeprecated 330 | deprecationReason 331 | } 332 | inputFields { 333 | ...InputValue 334 | } 335 | interfaces { 336 | ...TypeRef 337 | } 338 | enumValues(includeDeprecated: true) { 339 | name 340 | isDeprecated 341 | deprecationReason 342 | } 343 | possibleTypes { 344 | ...TypeRef 345 | } 346 | } 347 | fragment InputValue on __InputValue { 348 | name 349 | type { 350 | ...TypeRef 351 | } 352 | defaultValue 353 | } 354 | fragment TypeRef on __Type { 355 | kind 356 | name 357 | ofType { 358 | kind 359 | name 360 | ofType { 361 | kind 362 | name 363 | ofType { 364 | kind 365 | name 366 | ofType { 367 | kind 368 | name 369 | ofType { 370 | kind 371 | name 372 | ofType { 373 | kind 374 | name 375 | ofType { 376 | kind 377 | name 378 | } 379 | } 380 | } 381 | } 382 | } 383 | } 384 | } 385 | } 386 | query IntrospectionQuery { 387 | __schema { 388 | queryType { 389 | name 390 | } 391 | mutationType { 392 | name 393 | } 394 | types { 395 | ...FullType 396 | } 397 | directives { 398 | name 399 | locations 400 | args { 401 | ...InputValue 402 | } 403 | } 404 | } 405 | } 406 | """) 407 | --------------------------------------------------------------------------------