├── dwollav2 ├── version.py ├── __init__.py ├── test │ ├── __init__.py │ ├── test_response.py │ ├── test_client.py │ ├── test_error.py │ ├── test_auth.py │ └── test_token.py ├── response.py ├── client.py ├── auth.py ├── error.py └── token.py ├── MANIFEST.in ├── requirements.txt ├── Dockerfile ├── Pipfile ├── sample_app ├── main.py └── helpers.py ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── LICENSE.txt ├── .gitignore ├── setup.py ├── Pipfile.lock └── README.md /dwollav2/version.py: -------------------------------------------------------------------------------- 1 | version = '2.3.0' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.27.0 2 | responses>=0.9.0 3 | unittest2>=1.1.0 4 | mock>=2.0.0 5 | -------------------------------------------------------------------------------- /dwollav2/__init__.py: -------------------------------------------------------------------------------- 1 | from dwollav2.client import Client 2 | from dwollav2.response import Response 3 | from dwollav2.error import * 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /dwollav2 4 | 5 | COPY requirements.txt ./ 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | COPY . . 10 | 11 | CMD ["python", "setup.py", "test"] 12 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | unittest2 = "*" 8 | responses = "*" 9 | mock = "*" 10 | 11 | [packages] 12 | requests = "2.27" 13 | dwollav2 = {editable = true,path = "."} 14 | 15 | [requires] 16 | python_version = "3.7" 17 | -------------------------------------------------------------------------------- /dwollav2/test/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | def all(): 6 | path = os.path.dirname(os.path.realpath(__file__)) 7 | return unittest.defaultTestLoader.discover(path) 8 | 9 | 10 | def resources(): 11 | path = os.path.dirname(os.path.realpath(__file__)) 12 | return unittest.defaultTestLoader.discover( 13 | os.path.join(path, "resources")) 14 | -------------------------------------------------------------------------------- /dwollav2/response.py: -------------------------------------------------------------------------------- 1 | from dwollav2.error import Error 2 | 3 | 4 | class Response: 5 | def __init__(self, res): 6 | if (res.status_code >= 400): 7 | raise Error.map(res) 8 | 9 | self.status = res.status_code 10 | self.headers = res.headers 11 | self.body = self._get_body(res) 12 | 13 | def _get_body(self, res): 14 | try: 15 | return res.json() 16 | except: 17 | return res.text 18 | -------------------------------------------------------------------------------- /sample_app/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from helpers import * 3 | 4 | exit = False 5 | 6 | # Get DWOLLA_APP_KEY and DWOLLA_APP_KEY from environment variables 7 | DWOLLA_APP_KEY = os.getenv('DWOLLA_APP_KEY') 8 | DWOLLA_APP_SECRET = os.environ.get('DWOLLA_APP_SECRET') 9 | 10 | while not exit: 11 | display_options() 12 | input = get_user_input() 13 | if input == 'exit': 14 | exit = True 15 | else: 16 | handle_input(input, DWOLLA_APP_KEY, DWOLLA_APP_SECRET) 17 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | - name: Run tests 30 | run: | 31 | python setup.py test 32 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: '3.8' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install build 27 | - name: Build package 28 | run: python -m build 29 | - name: Publish package 30 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Dwolla, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /dwollav2/test/test_response.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | 4 | import dwollav2 5 | 6 | 7 | class ResponseShould(unittest.TestCase): 8 | def test_raises_error_if_over_400(self): 9 | res = requests.Response() 10 | res.status_code = 400 11 | with self.assertRaises(dwollav2.Error): 12 | dwollav2.Response(res) 13 | 14 | def test_sets_status(self): 15 | res = requests.Response() 16 | res.status_code = 200 17 | dres = dwollav2.Response(res) 18 | self.assertEqual(res.status_code, dres.status) 19 | 20 | def test_sets_headers(self): 21 | res = requests.Response() 22 | res.status_code = 200 23 | res.headers = {'foo': 'bar'} 24 | dres = dwollav2.Response(res) 25 | self.assertEqual(res.headers, dres.headers) 26 | 27 | def test_sets_text_body(self): 28 | res = requests.Response() 29 | res.status_code = 200 30 | res._content = 'foo bar'.encode() 31 | dres = dwollav2.Response(res) 32 | self.assertEqual(res.text, dres.body) 33 | 34 | def test_sets_json_body(self): 35 | res = requests.Response() 36 | res.status_code = 200 37 | res._content = '{"foo":"bar"}'.encode() 38 | dres = dwollav2.Response(res) 39 | self.assertEqual(res.json(), dres.body) 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask instance folder 57 | instance/ 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # IPython Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # dotenv 78 | .env 79 | 80 | # virtualenv 81 | venv/ 82 | ENV/ 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # Visual Studio Code 91 | .vscode/ 92 | 93 | # IntelliJ Idea 94 | .idea/ 95 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import warnings 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | setup( 11 | name='dwollav2', 12 | version='2.3.0', 13 | packages=['dwollav2'], 14 | install_requires=[ 15 | 'requests>=2.9.1', 16 | ], 17 | test_suite='dwollav2.test.all', 18 | url='https://docsv2.dwolla.com', 19 | license='MIT', 20 | author='Stephen Ausman', 21 | author_email='stephen@dwolla.com', 22 | long_description=open('README.md').read(), 23 | long_description_content_type="text/markdown", 24 | description='Official Dwolla V2 API client', 25 | classifiers=[ 26 | "Development Status :: 4 - Beta", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 2.6", 32 | "Programming Language :: Python :: 2.7", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.2", 35 | "Programming Language :: Python :: 3.4", 36 | "Programming Language :: Python :: 3.5", 37 | "Programming Language :: Python :: 3.6", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: Implementation :: PyPy", 40 | "Topic :: Software Development :: Libraries :: Python Modules", 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /dwollav2/client.py: -------------------------------------------------------------------------------- 1 | from dwollav2.auth import auth_for 2 | from dwollav2.token import token_for 3 | from dwollav2.version import version 4 | 5 | import requests 6 | 7 | 8 | class Client: 9 | ENVIRONMENTS = { 10 | 'production': { 11 | 'auth_url': 'https://accounts.dwolla.com/auth', 12 | 'token_url': 'https://api.dwolla.com/token', 13 | 'api_url': 'https://api.dwolla.com' 14 | }, 15 | 'sandbox': { 16 | 'auth_url': 'https://accounts-sandbox.dwolla.com/auth', 17 | 'token_url': 'https://api-sandbox.dwolla.com/token', 18 | 'api_url': 'https://api-sandbox.dwolla.com' 19 | } 20 | } 21 | 22 | def __init__(self, **kwargs): 23 | self.id = self.key = kwargs.get('id') or kwargs['key'] 24 | self.secret = kwargs['secret'] 25 | self.environment = kwargs.get('environment', 'production') 26 | if self.environment not in self.ENVIRONMENTS: 27 | raise ValueError('invalid environment') 28 | self.on_grant = kwargs.get('on_grant') 29 | self.requests = kwargs.get('requests', {}) 30 | self.Auth = auth_for(self) 31 | self.Token = token_for(self) 32 | self._session = requests.session() 33 | self._session.headers.update( 34 | {'user-agent': 'dwolla-v2-python %s' % version}) 35 | 36 | def auth(self, opts=None, **kwargs): 37 | return self.Auth(opts, **kwargs) 38 | 39 | def refresh_token(self, opts=None, **kwargs): 40 | return self.Auth.refresh(self.Token(opts, **kwargs)) 41 | 42 | def token(self, opts=None, **kwargs): 43 | return self.Token(opts, **kwargs) 44 | 45 | @property 46 | def auth_url(self): 47 | return self.ENVIRONMENTS[self.environment]['auth_url'] 48 | 49 | @property 50 | def token_url(self): 51 | return self.ENVIRONMENTS[self.environment]['token_url'] 52 | 53 | @property 54 | def api_url(self): 55 | return self.ENVIRONMENTS[self.environment]['api_url'] 56 | 57 | def Token(self, opts=None, **kwargs): 58 | return Token(self, opts, **kwargs) 59 | -------------------------------------------------------------------------------- /dwollav2/auth.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.parse import urlencode 3 | except ImportError: 4 | from urllib import urlencode 5 | 6 | from dwollav2.error import Error 7 | 8 | 9 | def _is_error(res): 10 | try: 11 | return 'error' in res.json() 12 | except: 13 | return True 14 | 15 | 16 | def _request_token(client, payload): 17 | res = client._session.post( 18 | client.token_url, data=payload, **client.requests) 19 | if _is_error(res): 20 | raise Error.map(res) 21 | token = client.Token(res.json()) 22 | if client.on_grant is not None: 23 | client.on_grant(token) 24 | return token 25 | 26 | 27 | def auth_for(_client): 28 | class Auth: 29 | def __init__(self, opts=None, **kwargs): 30 | opts = kwargs if opts is None else opts 31 | self.redirect_uri = opts.get('redirect_uri') 32 | self.scope = opts.get('scope') 33 | self.state = opts.get('state') 34 | self.verified_account = opts.get('verified_account') 35 | self.dwolla_landing = opts.get('dwolla_landing') 36 | 37 | @property 38 | def url(self): 39 | return '%s?%s' % (_client.auth_url, urlencode(self._query())) 40 | 41 | def callback(self, params=None, **kwargs): 42 | params = kwargs if params is None else params 43 | if params.get('state') != self.state: 44 | raise ValueError('invalid state') 45 | if 'error' in params: 46 | raise Error.map(params) 47 | return _request_token(_client, { 48 | 'client_id': _client.id, 49 | 'client_secret': _client.secret, 50 | 'grant_type': 'authorization_code', 51 | 'code': params['code'], 52 | 'redirect_uri': self.redirect_uri 53 | }) 54 | 55 | def _query(self): 56 | d = { 57 | 'response_type': 'code', 58 | 'client_id': _client.id, 59 | 'redirect_uri': self.redirect_uri, 60 | 'scope': self.scope, 61 | 'state': self.state, 62 | 'verified_account': self.verified_account, 63 | 'dwolla_landing': self.dwolla_landing 64 | } 65 | return dict((k, v) for k, v in iter(sorted(d.items())) if v is not None) 66 | 67 | @staticmethod 68 | def client(): 69 | return _request_token(_client, { 70 | 'client_id': _client.id, 71 | 'client_secret': _client.secret, 72 | 'grant_type': 'client_credentials' 73 | }) 74 | 75 | @staticmethod 76 | def refresh(token): 77 | return _request_token(_client, { 78 | 'client_id': _client.id, 79 | 'client_secret': _client.secret, 80 | 'grant_type': 'refresh_token', 81 | 'refresh_token': token.refresh_token 82 | }) 83 | 84 | return Auth 85 | -------------------------------------------------------------------------------- /dwollav2/test/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import responses 3 | 4 | import dwollav2 5 | 6 | 7 | class ClientShould(unittest.TestCase): 8 | id = 'client-id' 9 | secret = 'client-secret' 10 | 11 | def test_sets_id_and_key_when_id_provided(self): 12 | client = dwollav2.Client(id=self.id, secret=self.secret) 13 | self.assertEqual(self.id, client.id) 14 | self.assertEqual(self.id, client.key) 15 | 16 | def test_sets_id_and_key_when_key_provided(self): 17 | client = dwollav2.Client(key=self.id, secret=self.secret) 18 | self.assertEqual(self.id, client.id) 19 | self.assertEqual(self.id, client.key) 20 | 21 | def test_sets_secret(self): 22 | client = dwollav2.Client(id=self.id, secret=self.secret) 23 | self.assertEqual(self.secret, client.secret) 24 | 25 | def test_sets_environment(self): 26 | client = dwollav2.Client( 27 | id=self.id, secret=self.secret, environment='sandbox') 28 | self.assertEqual('sandbox', client.environment) 29 | 30 | def test_raises_if_invalid_environment(self): 31 | with self.assertRaises(ValueError): 32 | dwollav2.Client(id=self.id, secret=self.secret, 33 | environment='invalid') 34 | 35 | def test_sets_on_grant(self): 36 | def on_grant(x): return x 37 | client = dwollav2.Client( 38 | id=self.id, secret=self.secret, on_grant=on_grant) 39 | self.assertEqual(on_grant, client.on_grant) 40 | 41 | def test_auth_url(self): 42 | client = dwollav2.Client(id=self.id, secret=self.secret) 43 | self.assertEqual( 44 | client.ENVIRONMENTS[client.environment]['auth_url'], client.auth_url) 45 | 46 | def test_token_url(self): 47 | client = dwollav2.Client(id=self.id, secret=self.secret) 48 | self.assertEqual( 49 | client.ENVIRONMENTS[client.environment]['token_url'], client.token_url) 50 | 51 | def test_api_url(self): 52 | client = dwollav2.Client(id=self.id, secret=self.secret) 53 | self.assertEqual( 54 | client.ENVIRONMENTS[client.environment]['api_url'], client.api_url) 55 | 56 | def test_auth(self): 57 | client = dwollav2.Client(id=self.id, secret=self.secret) 58 | redirect_uri = "redirect-uri" 59 | self.assertEqual(client.auth( 60 | redirect_uri=redirect_uri).url, 'https://accounts.dwolla.com/auth?client_id=client-id&redirect_uri=%s&response_type=code' % redirect_uri) 61 | 62 | @responses.activate 63 | def test_refresh_token_success(self): 64 | client = dwollav2.Client(id=self.id, secret=self.secret) 65 | responses.add(responses.POST, 66 | client.token_url, 67 | body='{"access_token": "abc"}', 68 | status=200, 69 | content_type='application/json') 70 | token = client.refresh_token(refresh_token='refresh-token') 71 | self.assertEqual('abc', token.access_token) 72 | 73 | @responses.activate 74 | def test_refresh_token_error(self): 75 | client = dwollav2.Client(id=self.id, secret=self.secret) 76 | responses.add(responses.POST, 77 | client.token_url, 78 | body='{"error": "bad"}', 79 | status=200, 80 | content_type='application/json') 81 | with self.assertRaises(dwollav2.Error): 82 | client.refresh_token(refresh_token='refresh-token') 83 | 84 | def test_token(self): 85 | client = dwollav2.Client(id=self.id, secret=self.secret) 86 | access_token = "access-token" 87 | self.assertEqual(client.token( 88 | access_token=access_token).access_token, access_token) 89 | -------------------------------------------------------------------------------- /dwollav2/test/test_error.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | 4 | import dwollav2 5 | 6 | 7 | class ErrorShould(unittest.TestCase): 8 | def test_maps_string_to_generic_error(self): 9 | error = 'foo' 10 | with self.assertRaises(dwollav2.Error) as ecm: 11 | raise dwollav2.Error.map(error) 12 | self.assertEqual(error, str(ecm.exception)) 13 | self.assertEqual(error, ecm.exception.body) 14 | 15 | def test_maps_dict_to_specific_error(self): 16 | error = {'error': 'invalid_request'} 17 | with self.assertRaises(dwollav2.InvalidRequestError) as ecm: 18 | raise dwollav2.Error.map(error) 19 | self.assertEqual(str(error), str(ecm.exception)) 20 | self.assertEqual(error, ecm.exception.body) 21 | 22 | def test_maps_response(self): 23 | res = requests.Response() 24 | res.status_code = 420 25 | res.headers = {'foo': 'bar'} 26 | res._content = '{"error":"invalid_request"}'.encode() 27 | with self.assertRaises(dwollav2.InvalidRequestError) as ecm: 28 | raise dwollav2.Error.map(res) 29 | self.assertEqual(res.text, str(ecm.exception)) 30 | self.assertEqual(res.status_code, ecm.exception.status) 31 | self.assertEqual(res.headers, ecm.exception.headers) 32 | self.assertEqual(res.json(), ecm.exception.body) 33 | 34 | def test_maps_codes(self): 35 | self._test_maps_code_to_error('access_denied', dwollav2.AccessDeniedError) 36 | self._test_maps_code_to_error('InvalidCredentials', dwollav2.InvalidCredentialsError) 37 | self._test_maps_code_to_error('NotFound', dwollav2.NotFoundError) 38 | self._test_maps_code_to_error('BadRequest', dwollav2.BadRequestError) 39 | self._test_maps_code_to_error('invalid_grant', dwollav2.InvalidGrantError) 40 | self._test_maps_code_to_error('RequestTimeout', dwollav2.RequestTimeoutError) 41 | self._test_maps_code_to_error('ExpiredAccessToken', dwollav2.ExpiredAccessTokenError) 42 | self._test_maps_code_to_error('invalid_request', dwollav2.InvalidRequestError) 43 | self._test_maps_code_to_error('ServerError', dwollav2.ServerError) 44 | self._test_maps_code_to_error('Forbidden', dwollav2.ForbiddenError) 45 | self._test_maps_code_to_error('InvalidResourceState', dwollav2.InvalidResourceStateError) 46 | self._test_maps_code_to_error('temporarily_unavailable', dwollav2.TemporarilyUnavailableError) 47 | self._test_maps_code_to_error('InvalidAccessToken', dwollav2.InvalidAccessTokenError) 48 | self._test_maps_code_to_error('InvalidScope', dwollav2.InvalidScopeError) 49 | self._test_maps_code_to_error('unauthorized_client', dwollav2.UnauthorizedClientError) 50 | self._test_maps_code_to_error('InvalidAccountStatus', dwollav2.InvalidAccountStatusError) 51 | self._test_maps_code_to_error('unsupported_grant_type', dwollav2.UnsupportedGrantTypeError) 52 | self._test_maps_code_to_error('InvalidApplicationStatus', dwollav2.InvalidApplicationStatusError) 53 | self._test_maps_code_to_error('InvalidVersion', dwollav2.InvalidVersionError) 54 | self._test_maps_code_to_error('unsupported_response_type', dwollav2.UnsupportedResponseTypeError) 55 | self._test_maps_code_to_error('invalid_client', dwollav2.InvalidClientError) 56 | self._test_maps_code_to_error('method_not_allowed', dwollav2.MethodNotAllowedError) 57 | self._test_maps_code_to_error('ValidationError', dwollav2.ValidationError) 58 | self._test_maps_code_to_error('TooManyRequests', dwollav2.TooManyRequestsError) 59 | self._test_maps_code_to_error('Conflict', dwollav2.ConflictError) 60 | self._test_maps_code_to_error('UpdateCredentials', dwollav2.UpdateCredentialsError) 61 | 62 | def _test_maps_code_to_error(self, code, klass): 63 | error = {'error': code} 64 | error2 = {'code': code} 65 | with self.assertRaises(klass): 66 | raise dwollav2.Error.map(error) 67 | with self.assertRaises(klass): 68 | raise dwollav2.Error.map(error2) 69 | -------------------------------------------------------------------------------- /dwollav2/error.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class Error(Exception): 5 | def __init__(self, res): 6 | if isinstance(res, requests.Response): 7 | super(Error, self).__init__(res.text) 8 | self.status = res.status_code 9 | self.headers = res.headers 10 | self.body = self._get_body(res) 11 | else: 12 | super(Error, self).__init__(str(res)) 13 | self.body = res 14 | 15 | @staticmethod 16 | def map(res): 17 | try: 18 | if isinstance(res, requests.Response): 19 | body = res.json() 20 | else: 21 | body = res 22 | c = body.get('code', body.get('error')) 23 | except: 24 | c = None 25 | 26 | return { 27 | 'access_denied': AccessDeniedError, 28 | 'InvalidCredentials': InvalidCredentialsError, 29 | 'NotFound': NotFoundError, 30 | 'BadRequest': BadRequestError, 31 | 'invalid_grant': InvalidGrantError, 32 | 'RequestTimeout': RequestTimeoutError, 33 | 'ExpiredAccessToken': ExpiredAccessTokenError, 34 | 'invalid_request': InvalidRequestError, 35 | 'ServerError': ServerError, 36 | 'Forbidden': ForbiddenError, 37 | 'InvalidResourceState': InvalidResourceStateError, 38 | 'temporarily_unavailable': TemporarilyUnavailableError, 39 | 'InvalidAccessToken': InvalidAccessTokenError, 40 | 'InvalidScope': InvalidScopeError, 41 | 'unauthorized_client': UnauthorizedClientError, 42 | 'InvalidAccountStatus': InvalidAccountStatusError, 43 | 'unsupported_grant_type': UnsupportedGrantTypeError, 44 | 'InvalidApplicationStatus': InvalidApplicationStatusError, 45 | 'InvalidVersion': InvalidVersionError, 46 | 'unsupported_response_type': UnsupportedResponseTypeError, 47 | 'invalid_client': InvalidClientError, 48 | 'method_not_allowed': MethodNotAllowedError, 49 | 'ValidationError': ValidationError, 50 | 'TooManyRequests': TooManyRequestsError, 51 | 'Conflict': ConflictError, 52 | 'UpdateCredentials': UpdateCredentialsError, 53 | }.get(c, Error)(res) 54 | 55 | def _get_body(self, res): 56 | try: 57 | return res.json() 58 | except: 59 | return res.text 60 | 61 | 62 | class AccessDeniedError(Error): 63 | pass 64 | 65 | 66 | class InvalidCredentialsError(Error): 67 | pass 68 | 69 | 70 | class NotFoundError(Error): 71 | pass 72 | 73 | 74 | class BadRequestError(Error): 75 | pass 76 | 77 | 78 | class InvalidGrantError(Error): 79 | pass 80 | 81 | 82 | class RequestTimeoutError(Error): 83 | pass 84 | 85 | 86 | class ExpiredAccessTokenError(Error): 87 | pass 88 | 89 | 90 | class InvalidRequestError(Error): 91 | pass 92 | 93 | 94 | class ServerError(Error): 95 | pass 96 | 97 | 98 | class ForbiddenError(Error): 99 | pass 100 | 101 | 102 | class InvalidResourceStateError(Error): 103 | pass 104 | 105 | 106 | class TemporarilyUnavailableError(Error): 107 | pass 108 | 109 | 110 | class InvalidAccessTokenError(Error): 111 | pass 112 | 113 | 114 | class InvalidScopeError(Error): 115 | pass 116 | 117 | 118 | class UnauthorizedClientError(Error): 119 | pass 120 | 121 | 122 | class InvalidAccountStatusError(Error): 123 | pass 124 | 125 | 126 | class UnsupportedGrantTypeError(Error): 127 | pass 128 | 129 | 130 | class InvalidApplicationStatusError(Error): 131 | pass 132 | 133 | 134 | class InvalidVersionError(Error): 135 | pass 136 | 137 | 138 | class UnsupportedResponseTypeError(Error): 139 | pass 140 | 141 | 142 | class InvalidClientError(Error): 143 | pass 144 | 145 | 146 | class MethodNotAllowedError(Error): 147 | pass 148 | 149 | 150 | class ValidationError(Error): 151 | pass 152 | 153 | 154 | class TooManyRequestsError(Error): 155 | pass 156 | 157 | 158 | class ConflictError(Error): 159 | pass 160 | 161 | 162 | class UpdateCredentialsError(Error): 163 | pass 164 | 165 | -------------------------------------------------------------------------------- /dwollav2/token.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from io import IOBase 3 | import re 4 | import json 5 | import decimal 6 | 7 | from dwollav2.response import Response 8 | from dwollav2.version import version 9 | 10 | 11 | class DecimalEncoder(json.JSONEncoder): 12 | def default(self, o): 13 | if isinstance(o, decimal.Decimal): 14 | return str(o) 15 | return super().default(o) 16 | 17 | 18 | def _items_or_iteritems(o): 19 | try: 20 | return o.iteritems() 21 | except: 22 | return o.items() 23 | 24 | 25 | def _is_a_file(o): 26 | try: 27 | return isinstance(o, file) or isinstance(o, IOBase) 28 | except NameError as e: 29 | return isinstance(o, IOBase) 30 | 31 | 32 | def _contains_file(o): 33 | if isinstance(o, dict): 34 | for k, v in _items_or_iteritems(o): 35 | if _contains_file(v): 36 | return True 37 | return False 38 | elif isinstance(o, tuple) or isinstance(o, list): 39 | for v in o: 40 | if _contains_file(v): 41 | return True 42 | return False 43 | else: 44 | return _is_a_file(o) 45 | 46 | 47 | def token_for(_client): 48 | class Token: 49 | def __init__(self, opts=None, **kwargs): 50 | opts = kwargs if opts is None else opts 51 | self.access_token = opts.get('access_token') 52 | self.refresh_token = opts.get('refresh_token') 53 | self.expires_in = opts.get('expires_in') 54 | self.scope = opts.get('scope') 55 | self.app_id = opts.get('app_id') 56 | self.account_id = opts.get('account_id') 57 | 58 | self._session = requests.session() 59 | self._session.headers.update({ 60 | 'accept': 'application/vnd.dwolla.v1.hal+json', 61 | 'user-agent': 'dwolla-v2-python %s' % version, 62 | 'authorization': 'Bearer %s' % self.access_token 63 | }) 64 | 65 | def post(self, url, body=None, headers={}, **kwargs): 66 | body = kwargs if body is None else body 67 | requests = _client.requests.copy() 68 | headers = self._merge_dicts( 69 | requests.pop('headers', {}), headers) 70 | if _contains_file(body): 71 | files = [(k, v) for k, v in _items_or_iteritems( 72 | body) if _contains_file(v)] 73 | data = [(k, v) for k, v in _items_or_iteritems( 74 | body) if not _contains_file(v)] 75 | return Response(self._session.post(self._full_url(url), headers=headers, files=files, data=data, **requests)) 76 | else: 77 | return Response(self._session.post( 78 | self._full_url(url), 79 | headers=self._merge_dicts( 80 | {'content-type': 'application/json'}, headers), 81 | data=json.dumps(body, sort_keys=True, indent=2, cls=DecimalEncoder), 82 | **requests)) 83 | 84 | def get(self, url, params=None, headers={}, **kwargs): 85 | params = kwargs if params is None else params 86 | requests = _client.requests.copy() 87 | headers = self._merge_dicts( 88 | requests.pop('headers', {}), headers) 89 | return Response(self._session.get(self._full_url(url), headers=headers, params=params, **requests)) 90 | 91 | def delete(self, url, params=None, headers={}): 92 | requests = _client.requests.copy() 93 | headers = self._merge_dicts( 94 | requests.pop('headers', {}), headers) 95 | return Response(self._session.delete(self._full_url(url), headers=headers, params=params, **requests)) 96 | 97 | def _full_url(self, path): 98 | if isinstance(path, dict): 99 | path = path['_links']['self']['href'] 100 | if path.startswith(_client.api_url) and _client.api_url[-1] == '/': 101 | return path 102 | elif path.startswith('/'): 103 | return _client.api_url + path 104 | else: 105 | path = re.sub(r'^https?://[^/]*/', '', path) 106 | return "%s/%s" % (_client.api_url, path) 107 | 108 | def _merge_dicts(self, x, y): 109 | z = x.copy() # start with x's keys and values 110 | z.update(y) # modifies z with y's keys and values & returns None 111 | return z 112 | 113 | return Token 114 | -------------------------------------------------------------------------------- /dwollav2/test/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import responses 3 | from mock import Mock 4 | try: 5 | from urllib.parse import urlencode 6 | except ImportError: 7 | from urllib import urlencode 8 | 9 | import dwollav2 10 | 11 | 12 | class AuthShould(unittest.TestCase): 13 | client = dwollav2.Client(id='id', secret='secret', on_grant=Mock()) 14 | redirect_uri = 'redirect uri' 15 | scope = 'scope' 16 | state = 'state' 17 | 18 | def test_instance_sets_redirect_uri(self): 19 | auth = self.client.Auth(redirect_uri=self.redirect_uri) 20 | self.assertEqual(self.redirect_uri, auth.redirect_uri) 21 | 22 | def test_instance_sets_scope(self): 23 | auth = self.client.Auth(scope=self.scope) 24 | self.assertEqual(self.scope, auth.scope) 25 | 26 | def test_instance_sets_state(self): 27 | auth = self.client.Auth(state=self.state) 28 | self.assertEqual(self.state, auth.state) 29 | 30 | def test_instance_url(self): 31 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 32 | scope=self.scope, 33 | state=self.state) 34 | expected_url = '%s' % self.client.auth_url 35 | self.assertEqual(expected_url, auth.url) 36 | 37 | def test_instance_url(self): 38 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 39 | scope=self.scope, 40 | state=self.state) 41 | expected_url = self.client.auth_url + '?' + \ 42 | self._expected_query(self.client, auth) 43 | self.assertEqual(expected_url, auth.url) 44 | 45 | def test_instance_callback_raises_error_if_state_mismatch(self): 46 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 47 | scope=self.scope, 48 | state=self.state) 49 | params = {'state': self.state + 'bad'} 50 | with self.assertRaises(ValueError): 51 | auth.callback(params) 52 | 53 | def test_instance_callback_raises_error_if_passed_error(self): 54 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 55 | scope=self.scope, 56 | state=self.state) 57 | params = {'error': 'bad', 'state': self.state} 58 | with self.assertRaises(dwollav2.Error): 59 | auth.callback(params) 60 | 61 | @responses.activate 62 | def test_instance_callback_success(self): 63 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 64 | scope=self.scope, 65 | state=self.state) 66 | responses.add(responses.POST, 67 | self.client.token_url, 68 | body='{"access_token": "abc"}', 69 | status=200, 70 | content_type='application/json') 71 | params = {'state': self.state, 'code': 'def'} 72 | token = auth.callback(params) 73 | self.assertEqual('abc', token.access_token) 74 | self.client.on_grant.assert_called_with(token) 75 | 76 | @responses.activate 77 | def test_instance_callback_error(self): 78 | auth = self.client.Auth(redirect_uri=self.redirect_uri, 79 | scope=self.scope, 80 | state=self.state) 81 | responses.add(responses.POST, 82 | self.client.token_url, 83 | body='{"error": "bad"}', 84 | status=200, 85 | content_type='application/json') 86 | params = {'state': self.state, 'code': 'def'} 87 | with self.assertRaises(dwollav2.Error): 88 | auth.callback(params) 89 | 90 | @responses.activate 91 | def test_instance_callback_success_with_none_on_grant(self): 92 | client = dwollav2.Client(id='id', secret='secret') 93 | auth = client.Auth(redirect_uri=self.redirect_uri, 94 | scope=self.scope, 95 | state=self.state) 96 | responses.add(responses.POST, 97 | client.token_url, 98 | body='{"access_token": "abc"}', 99 | status=200, 100 | content_type='application/json') 101 | params = {'state': self.state, 'code': 'def'} 102 | token = auth.callback(params) 103 | self.assertEqual('abc', token.access_token) 104 | 105 | @responses.activate 106 | def test_class_client_success(self): 107 | responses.add(responses.POST, 108 | self.client.token_url, 109 | body='{"access_token": "abc"}', 110 | status=200, 111 | content_type='application/json') 112 | token = self.client.Auth.client() 113 | self.assertEqual('abc', token.access_token) 114 | self.client.on_grant.assert_called_with(token) 115 | 116 | @responses.activate 117 | def test_class_client_error(self): 118 | responses.add(responses.POST, 119 | self.client.token_url, 120 | body='{"error": "bad"}', 121 | status=200, 122 | content_type='application/json') 123 | with self.assertRaises(dwollav2.Error): 124 | self.client.Auth.client() 125 | 126 | @responses.activate 127 | def test_class_refresh_success(self): 128 | responses.add(responses.POST, 129 | self.client.token_url, 130 | body='{"access_token": "abc"}', 131 | status=200, 132 | content_type='application/json') 133 | old_token = self.client.Token(refresh_token='refresh token') 134 | token = self.client.Auth.refresh(old_token) 135 | self.assertEqual('abc', token.access_token) 136 | self.client.on_grant.assert_called_with(token) 137 | 138 | @responses.activate 139 | def test_class_refresh_error(self): 140 | responses.add(responses.POST, 141 | self.client.token_url, 142 | body='{"error": "bad"}', 143 | status=200, 144 | content_type='application/json') 145 | old_token = self.client.Token(refresh_token='refresh token') 146 | with self.assertRaises(dwollav2.Error): 147 | self.client.Auth.refresh(old_token) 148 | 149 | def _expected_query(self, client, auth): 150 | d = { 151 | 'response_type': 'code', 152 | 'client_id': client.id, 153 | 'redirect_uri': auth.redirect_uri, 154 | 'scope': auth.scope, 155 | 'state': auth.state, 156 | 'verified_account': auth.verified_account, 157 | 'dwolla_landing': auth.dwolla_landing 158 | } 159 | return urlencode(dict((k, v) for k, v in iter(sorted(d.items())) if v)) 160 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "d49caa358e763efee50b3c8edf986877baa4dac6ea7ac78425dc61534ffc9912" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 22 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 23 | ], 24 | "version": "==2021.10.8" 25 | }, 26 | "charset-normalizer": { 27 | "hashes": [ 28 | "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", 29 | "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" 30 | ], 31 | "markers": "python_version >= '3'", 32 | "version": "==2.0.11" 33 | }, 34 | "dwollav2": { 35 | "editable": true, 36 | "path": "." 37 | }, 38 | "future": { 39 | "hashes": [ 40 | "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" 41 | ], 42 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 43 | "version": "==0.18.2" 44 | }, 45 | "idna": { 46 | "hashes": [ 47 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 48 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 49 | ], 50 | "markers": "python_version >= '3'", 51 | "version": "==3.3" 52 | }, 53 | "requests": { 54 | "hashes": [ 55 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 56 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 57 | ], 58 | "index": "pypi", 59 | "version": "==2.27.1" 60 | }, 61 | "urllib3": { 62 | "hashes": [ 63 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 64 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 65 | ], 66 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 67 | "version": "==1.26.8" 68 | } 69 | }, 70 | "develop": { 71 | "argparse": { 72 | "hashes": [ 73 | "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", 74 | "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314" 75 | ], 76 | "version": "==1.4.0" 77 | }, 78 | "certifi": { 79 | "hashes": [ 80 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 81 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 82 | ], 83 | "version": "==2021.10.8" 84 | }, 85 | "charset-normalizer": { 86 | "hashes": [ 87 | "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", 88 | "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" 89 | ], 90 | "markers": "python_version >= '3'", 91 | "version": "==2.0.11" 92 | }, 93 | "idna": { 94 | "hashes": [ 95 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 96 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 97 | ], 98 | "markers": "python_version >= '3'", 99 | "version": "==3.3" 100 | }, 101 | "linecache2": { 102 | "hashes": [ 103 | "sha256:4b26ff4e7110db76eeb6f5a7b64a82623839d595c2038eeda662f2a2db78e97c", 104 | "sha256:e78be9c0a0dfcbac712fe04fbf92b96cddae80b1b842f24248214c8496f006ef" 105 | ], 106 | "version": "==1.0.0" 107 | }, 108 | "mock": { 109 | "hashes": [ 110 | "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", 111 | "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc" 112 | ], 113 | "index": "pypi", 114 | "version": "==4.0.3" 115 | }, 116 | "requests": { 117 | "hashes": [ 118 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 119 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 120 | ], 121 | "index": "pypi", 122 | "version": "==2.27.1" 123 | }, 124 | "responses": { 125 | "hashes": [ 126 | "sha256:15c63ad16de13ee8e7182d99c9334f64fd81f1ee79f90748d527c28f7ca9dd51", 127 | "sha256:380cad4c1c1dc942e5e8a8eaae0b4d4edf708f4f010db8b7bcfafad1fcd254ff" 128 | ], 129 | "index": "pypi", 130 | "version": "==0.18.0" 131 | }, 132 | "six": { 133 | "hashes": [ 134 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 135 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 136 | ], 137 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 138 | "version": "==1.16.0" 139 | }, 140 | "traceback2": { 141 | "hashes": [ 142 | "sha256:05acc67a09980c2ecfedd3423f7ae0104839eccb55fc645773e1caa0951c3030", 143 | "sha256:8253cebec4b19094d67cc5ed5af99bf1dba1285292226e98a31929f87a5d6b23" 144 | ], 145 | "version": "==1.4.0" 146 | }, 147 | "unittest2": { 148 | "hashes": [ 149 | "sha256:13f77d0875db6d9b435e1d4f41e74ad4cc2eb6e1d5c824996092b3430f088bb8", 150 | "sha256:22882a0e418c284e1f718a822b3b022944d53d2d908e1690b319a9d3eb2c0579" 151 | ], 152 | "index": "pypi", 153 | "version": "==1.1.0" 154 | }, 155 | "urllib3": { 156 | "hashes": [ 157 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 158 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 159 | ], 160 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 161 | "version": "==1.26.8" 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /dwollav2/test/test_token.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import unittest 3 | import responses 4 | 5 | import dwollav2 6 | 7 | 8 | class TokenShould(unittest.TestCase): 9 | client = dwollav2.Client(id='id', secret='secret') 10 | access_token = 'access token' 11 | refresh_token = 'refresh token' 12 | expires_in = 123 13 | scope = 'scope' 14 | account_id = 'account id' 15 | more_headers = {'idempotency-key': 'foo'} 16 | 17 | def test_sets_access_token(self): 18 | token = self.client.Token(access_token=self.access_token) 19 | self.assertEqual(self.access_token, token.access_token) 20 | 21 | def test_sets_refresh_token(self): 22 | token = self.client.Token(refresh_token=self.refresh_token) 23 | self.assertEqual(self.refresh_token, token.refresh_token) 24 | 25 | def test_sets_expires_in(self): 26 | token = self.client.Token(expires_in=self.expires_in) 27 | self.assertEqual(self.expires_in, token.expires_in) 28 | 29 | def test_sets_scope(self): 30 | token = self.client.Token(scope=self.scope) 31 | self.assertEqual(self.scope, token.scope) 32 | 33 | def test_sets_account_id(self): 34 | token = self.client.Token(account_id=self.account_id) 35 | self.assertEqual(self.account_id, token.account_id) 36 | 37 | def test_uses_new_session(self): 38 | new_access_token = 'new access token' 39 | 40 | token1 = self.client.Token(access_token=self.access_token) 41 | token2 = self.client.Token(access_token=new_access_token) 42 | 43 | self.assertNotEqual(token1._session, token2._session) 44 | self.assertEqual( 45 | token1._session.headers['authorization'], 'Bearer %s' % self.access_token) 46 | self.assertEqual( 47 | token2._session.headers['authorization'], 'Bearer %s' % new_access_token) 48 | 49 | @responses.activate 50 | def test_get_success(self): 51 | responses.add(responses.GET, 52 | self.client.api_url + '/foo', 53 | body='{"foo": "bar"}', 54 | status=200, 55 | content_type='application/vnd.dwolla.v1.hal+json') 56 | token = self.client.Token(access_token=self.access_token) 57 | res = token.get('foo') 58 | self.assertEqual(200, res.status) 59 | self.assertEqual({'foo': 'bar'}, res.body) 60 | 61 | @responses.activate 62 | def test_get_success_leading_slash(self): 63 | responses.add(responses.GET, 64 | self.client.api_url + '/foo', 65 | body='{"foo": "bar"}', 66 | status=200, 67 | content_type='application/vnd.dwolla.v1.hal+json') 68 | token = self.client.Token(access_token=self.access_token) 69 | res = token.get('/foo') 70 | self.assertEqual(200, res.status) 71 | self.assertEqual({'foo': 'bar'}, res.body) 72 | 73 | @responses.activate 74 | def test_get_success_full_url(self): 75 | responses.add(responses.GET, 76 | self.client.api_url + '/foo', 77 | body='{"foo": "bar"}', 78 | status=200, 79 | content_type='application/vnd.dwolla.v1.hal+json') 80 | token = self.client.Token(access_token=self.access_token) 81 | res = token.get(self.client.api_url + '/foo') 82 | self.assertEqual(200, res.status) 83 | self.assertEqual({'foo': 'bar'}, res.body) 84 | 85 | @responses.activate 86 | def test_get_success_different_domain(self): 87 | responses.add(responses.GET, 88 | self.client.api_url + '/foo', 89 | body='{"foo": "bar"}', 90 | status=200, 91 | content_type='application/vnd.dwolla.v1.hal+json') 92 | token = self.client.Token(access_token=self.access_token) 93 | res = token.get('https://foo.com/foo') 94 | self.assertEqual(200, res.status) 95 | self.assertEqual({'foo': 'bar'}, res.body) 96 | 97 | @responses.activate 98 | def test_get_error(self): 99 | responses.add(responses.GET, 100 | self.client.api_url + '/foo', 101 | body='{"error": "bad"}', 102 | status=400, 103 | content_type='application/vnd.dwolla.v1.hal+json') 104 | token = self.client.Token(access_token=self.access_token) 105 | with self.assertRaises(dwollav2.Error): 106 | token.get('foo') 107 | 108 | @responses.activate 109 | def test_get_with_headers_success(self): 110 | responses.add(responses.GET, 111 | self.client.api_url + '/foo', 112 | body='{"foo": "bar"}', 113 | status=200, 114 | content_type='application/vnd.dwolla.v1.hal+json') 115 | token = self.client.Token(access_token=self.access_token) 116 | res = token.get('foo', None, self.more_headers) 117 | self.assertEqual(200, res.status) 118 | self.assertEqual({'foo': 'bar'}, res.body) 119 | 120 | @responses.activate 121 | def test_post_success(self): 122 | responses.add(responses.POST, 123 | self.client.api_url + '/foo', 124 | body='{"foo": "bar"}', 125 | status=200, 126 | content_type='application/vnd.dwolla.v1.hal+json') 127 | token = self.client.Token(access_token=self.access_token) 128 | res = token.post('foo') 129 | self.assertEqual(200, res.status) 130 | self.assertEqual({'foo': 'bar'}, res.body) 131 | 132 | @responses.activate 133 | def test_post_with_headers_success(self): 134 | responses.add(responses.POST, 135 | self.client.api_url + '/foo', 136 | body='{"foo": "bar"}', 137 | status=200, 138 | content_type='application/vnd.dwolla.v1.hal+json') 139 | token = self.client.Token(access_token=self.access_token) 140 | res = token.post('foo', None, self.more_headers) 141 | self.assertEqual(200, res.status) 142 | self.assertEqual({'foo': 'bar'}, res.body) 143 | 144 | @responses.activate 145 | def test_delete_success(self): 146 | responses.add(responses.DELETE, 147 | self.client.api_url + '/foo', 148 | body='{"foo": "bar"}', 149 | status=200, 150 | content_type='application/vnd.dwolla.v1.hal+json') 151 | token = self.client.Token(access_token=self.access_token) 152 | res = token.delete('foo') 153 | self.assertEqual(200, res.status) 154 | self.assertEqual({'foo': 'bar'}, res.body) 155 | 156 | @responses.activate 157 | def test_delete_with_headers_success(self): 158 | responses.add(responses.DELETE, 159 | self.client.api_url + '/foo', 160 | body='{"foo": "bar"}', 161 | status=200, 162 | content_type='application/vnd.dwolla.v1.hal+json') 163 | token = self.client.Token(access_token=self.access_token) 164 | res = token.delete('foo', None, self.more_headers) 165 | self.assertEqual(200, res.status) 166 | self.assertEqual({'foo': 'bar'}, res.body) 167 | 168 | @responses.activate 169 | def test_post_decimal(self): 170 | responses.add(responses.POST, 171 | self.client.api_url + '/foo', 172 | body='{"amount": "12.34"}', 173 | status=200, 174 | content_type='application/vnd.dwolla.v1.hal+json') 175 | token = self.client.Token(access_token=self.access_token) 176 | res = token.post('foo', body={'amount': decimal.Decimal('12.34')}) 177 | self.assertEqual(200, res.status) 178 | self.assertEqual({'amount': '12.34'}, res.body) -------------------------------------------------------------------------------- /sample_app/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import dwollav2 4 | 5 | 6 | def display_options(): 7 | print('Choose from the following actions: ') 8 | action_menu = ''' 9 | Root (R) 10 | Retrieve account details (RAD) 11 | Create account funding source (CAFS) 12 | Create account VAN (CAVAN) 13 | List account funding sources (LAFS) 14 | List and search account transfers (LASAT) 15 | List account mass payments (LAMP) 16 | Create receive only customer (CROC) 17 | Create unverified customer (CUVC) 18 | Create verified personal customer (CVP) 19 | Retrieve customer (RC) 20 | List and search customers (LASC) 21 | Update customer (UC) 22 | List customer business classifications (LCBC) 23 | retrieve business classification (RBC) 24 | initiate KBA (IKBA) 25 | retrieve KBA (RKBA) 26 | verify KBA (VKBA) 27 | Create beneficial owner (CBO) 28 | Retrieve beneficial owner (RBO) 29 | list beneficial owners (LBO) 30 | update beneficial owner (UBO) 31 | Remove beneficial owner (DBO) 32 | Retrieve beneficial ownership status (RBOS) 33 | Certify beneficial ownership (CBOS) 34 | Quit (Q) 35 | ''' 36 | print(action_menu) 37 | 38 | def get_user_input(): 39 | return input('Enter your action: ') 40 | 41 | def handle_input(input, DWOLLA_APP_KEY, DWOLLA_APP_SECRET): 42 | client = dwollav2.Client(key = DWOLLA_APP_KEY, secret = DWOLLA_APP_SECRET, environment = 'sandbox') 43 | application_token = client.Auth.client() 44 | if input == 'R': 45 | root(application_token) 46 | elif input == 'RAD': 47 | get_account_details(application_token) 48 | elif input == 'CAFS': 49 | create_account_funding_source(application_token) 50 | elif input == 'CAVAN': 51 | create_account_van(application_token) 52 | elif input == 'LAFS': 53 | list_account_funding_sources(application_token) 54 | elif input == 'LASAT': 55 | list_and_search_account_transfers(application_token) 56 | elif input == 'LAMP': 57 | list_account_mass_payments(application_token) 58 | elif input == 'CROC': 59 | create_receive_only_customer(application_token) 60 | elif input == 'CUVC': 61 | create_unverified_customer(application_token) 62 | elif input == 'CVP': 63 | create_verified_personal_customer(application_token) 64 | elif input == 'RC': 65 | retrieve_customer(application_token) 66 | elif input == 'LASC': 67 | list_and_search_customers(application_token) 68 | elif input == 'UC': 69 | update_customer(application_token) 70 | elif input == 'LCBC': 71 | list_customer_business_classifications(application_token) 72 | elif input == 'RBC': 73 | retrieve_business_classification(application_token) 74 | elif input == 'IKBA': 75 | initiate_kba(application_token) 76 | elif input == 'RKBA': 77 | retrieve_kba(application_token) 78 | elif input == 'VKBA': 79 | verify_kba(application_token) 80 | elif input == 'CBO': 81 | create_beneficial_owner(application_token) 82 | elif input == 'RBO': 83 | retrieve_beneficial_owner(application_token) 84 | elif input == 'LBO': 85 | list_beneficial_owners(application_token) 86 | elif input == 'UBO': 87 | update_beneficial_owner(application_token) 88 | elif input == 'DBO': 89 | remove_beneficial_owner(application_token) 90 | elif input == 'RBOS': 91 | retrieve_beneficial_ownership_status(application_token) 92 | elif input == 'CBOS': 93 | certify_beneficial_ownership(application_token) 94 | elif input == 'Q': 95 | quit() 96 | 97 | 98 | def print_response(res): 99 | print(json.dumps(res.body, indent = 4)) 100 | 101 | def print_location(res): 102 | print(res.headers['Location']) 103 | 104 | # ROOT RESOURCE 105 | def root(token): 106 | res = token.get('/') 107 | print_response(res) 108 | 109 | # ACCOUNT RESOURCE 110 | def get_account_details(token): 111 | id = input('Enter your account ID: ') 112 | res = token.get(f'accounts/{id}') 113 | print_response(res) 114 | 115 | def create_account_funding_source(token): 116 | accountNumber = input('Enter your account number: ') 117 | routingNumber = input('Enter your routing number: ') 118 | bankAccountType = input('Enter your bank account type: ') 119 | name = input('Enter your funding source nickname: ') 120 | 121 | body = { 122 | 'routingNumber': routingNumber, 123 | 'accountNumber': accountNumber, 124 | 'type': bankAccountType, 125 | 'name': name 126 | } 127 | 128 | res = token.post(f'/funding-sources', body) 129 | print_location(res) 130 | 131 | def create_account_van(token): 132 | name = input('Enter your account name: ') 133 | bankAccountType = input('Enter your bank account type: ') 134 | 135 | body = { 136 | 'name': name, 137 | 'type': 'virtual', 138 | 'bankAccountType': bankAccountType 139 | } 140 | 141 | res = token.post(f'/funding-sources', body) 142 | print_location(res) 143 | 144 | def list_account_funding_sources(token): 145 | id = input('Enter your account ID: ') 146 | res = token.get(f'/accounts/{id}/funding-sources') 147 | print_response(res) 148 | 149 | def list_and_search_account_transfers(token): 150 | id = input('Enter your account ID: ') 151 | res = token.get(f'/accounts/{id}/transfers') 152 | print_response(res) 153 | 154 | def list_account_mass_payments(token): 155 | id = input('Enter your account ID: ') 156 | res = token.get(f'/accounts/{id}/mass-payments') 157 | print_response(res) 158 | 159 | # CUSTOMER RESOURCE 160 | def create_receive_only_customer(token): 161 | firstName = input('Enter customer first name: ') 162 | lastName = input('Enter customer last name: ') 163 | email = input('Enter customer email: ') 164 | type = 'receive-only' 165 | 166 | body = { 167 | 'firstName': firstName, 168 | 'lastName': lastName, 169 | 'email': email, 170 | 'type': type 171 | } 172 | 173 | res = token.post(f'/customers', body) 174 | print_location(res) 175 | 176 | def create_unverified_customer(token): 177 | firstName = input('Enter customer first name: ') 178 | lastName = input('Enter customer last name: ') 179 | email = input('Enter customer email: ') 180 | 181 | body = { 182 | 'firstName': firstName, 183 | 'lastName': lastName, 184 | 'email': email, 185 | } 186 | 187 | res = token.post(f'/customers', body) 188 | print_location(res) 189 | 190 | def create_verified_personal_customer(token): 191 | firstName = input('Enter customer first name: ') 192 | lastName = input('Enter customer last name: ') 193 | email = input('Enter customer email: ') 194 | address1 = input('Enter customer address 1: ') 195 | city = input('Enter customer city: ') 196 | state = input('Enter customer state: ') 197 | postalCode = input('Enter customer postal code: ') 198 | dateOfBirth = input('Enter customer date of birth: ') 199 | ssn = input('Enter customer ssn: ') 200 | type = 'personal' 201 | 202 | body = { 203 | 'firstName': firstName, 204 | 'lastName': lastName, 205 | 'email': email, 206 | 'address1': address1, 207 | 'city': city, 208 | 'state': state, 209 | 'postalCode': postalCode, 210 | 'dateOfBirth': dateOfBirth, 211 | 'ssn': ssn, 212 | 'type': type 213 | } 214 | 215 | res = token.post(f'/customers', body) 216 | print_location(res) 217 | 218 | def retrieve_customer(token): 219 | id = input('Enter customer ID: ') 220 | res = token.get(f'/customers/{id}') 221 | print_response(res) 222 | 223 | def list_and_search_customers(token): 224 | res = token.get('/customers') 225 | print_response(res) 226 | 227 | def update_customer(token): 228 | id = input('Enter customer ID: ') 229 | email = input('Enter updated customer email: ') 230 | 231 | body = { 232 | 'email': email, 233 | } 234 | 235 | res = token.post(f'/customers/{id}', body) 236 | print_response(res) 237 | 238 | def list_customer_business_classifications(token): 239 | res = token.get('/business-classifications') 240 | print_response(res) 241 | 242 | def retrieve_business_classification(token): 243 | id = input('Enter business classification ID: ') 244 | res = token.get(f'/business-classifications/{id}') 245 | print_response(res) 246 | 247 | # KBA RESOURCE 248 | def initiate_kba(token): 249 | id = input('Enter customer ID: ') 250 | res = token.post(f'/customers/{id}/kba') 251 | print_location(res) 252 | 253 | def retrieve_kba(token): 254 | id = input('Enter KBA session ID: ') 255 | res = token.get(f'/kba/{id}') 256 | print_response(res) 257 | 258 | def verify_kba(token): 259 | id = input('Enter KBA session ID: ') 260 | 261 | answers = [] 262 | for i in range(4): 263 | obj = {} 264 | question_id = input(f'Enter question ID for question {i+1}: ') 265 | answer_id = input(f'Enter answer ID for answer {i+1}: ') 266 | obj['questionId'] = question_id 267 | obj['answerId'] = answer_id 268 | answers.append(obj) 269 | 270 | body = { 271 | 'answers': answers 272 | } 273 | 274 | res = token.post(f'/kba/{id}', body) 275 | print_response(res) 276 | 277 | # BENEFICIAL OWNERS RESOURCE 278 | def create_beneficial_owner(token): 279 | id = input('Enter customer ID: ') 280 | firstName = input('Enter beneficial owner first name: ') 281 | lastName = input('Enter beneficial owner last name: ') 282 | dateOfBirth = input('Enter beneficial owner date of birth: ') 283 | ssn = input('Enter beneficial owner ssn: ') 284 | address1 = input('Enter beneficial owner address 1: ') 285 | city = input('Enter beneficial owner city: ') 286 | state = input('Enter beneficial owner state: ') 287 | country = input('Enter beneficial owner country: ') 288 | postalCode = input('Enter beneficial owner postal code: ') 289 | 290 | body = { 291 | 'firstName': firstName, 292 | 'lastName': lastName, 293 | 'dateOfBirth': dateOfBirth, 294 | 'ssn': ssn, 295 | 'address': { 296 | 'address1': address1, 297 | 'city': city, 298 | 'stateProvinceRegion': state, 299 | 'country': country, 300 | 'postalCode': postalCode 301 | } 302 | } 303 | 304 | res = token.post(f'/customers/{id}/beneficial-owners', body) 305 | print_location(res) 306 | 307 | def retrieve_beneficial_owner(token): 308 | id = input('Enter beneficial owner ID: ') 309 | res = token.get(f'/beneficial-owners/{id}') 310 | print_response(res) 311 | 312 | def list_beneficial_owners(token): 313 | id = input('Enter customer ID: ') 314 | res = token.get(f'/customers/{id}/beneficial-owners') 315 | print_response(res) 316 | 317 | def update_beneficial_owner(token): 318 | id = input('Enter beneficial owner ID: ') 319 | firstName = input('Enter beneficial owner first name: ') 320 | lastName = input('Enter beneficial owner last name: ') 321 | dateOfBirth = input('Enter beneficial owner date of birth: ') 322 | ssn = input('Enter beneficial owner ssn: ') 323 | address1 = input('Enter beneficial owner address 1: ') 324 | city = input('Enter beneficial owner city: ') 325 | state = input('Enter beneficial owner state: ') 326 | country = input('Enter beneficial owner country: ') 327 | postalCode = input('Enter beneficial owner postal code: ') 328 | 329 | body = { 330 | 'firstName': firstName, 331 | 'lastName': lastName, 332 | 'dateOfBirth': dateOfBirth, 333 | 'ssn': ssn, 334 | 'address': { 335 | 'address1': address1, 336 | 'city': city, 337 | 'stateProvinceRegion': state, 338 | 'country': country, 339 | 'postalCode': postalCode 340 | } 341 | } 342 | 343 | res = token.post(f'/beneficial-owners/{id}', body) 344 | print_response(res) 345 | 346 | def remove_beneficial_owner(token): 347 | id = input('Enter beneficial owner ID: ') 348 | res = token.delete(f'/beneficial-owners/{id}') 349 | print_response(res) 350 | 351 | def retrieve_beneficial_ownership_status(token): 352 | id = input('Enter customer ID: ') 353 | res = token.get(f'/customers/{id}/beneficial-ownership') 354 | print_response(res) 355 | 356 | def certify_beneficial_ownership(token): 357 | id = input('Enter customer ID: ') 358 | body = { 359 | 'status': 'certified' 360 | } 361 | res = token.post(f'/customers/{id}/beneficial-ownership', body) 362 | print_response(res) 363 | 364 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dwolla SDK for Python 2 | 3 | This repository contains the source code for Dwolla's Python-based SDK, which allows developers to interact with Dwolla's server-side API via a Python API. Any action that can be performed via an HTTP request can be made using this SDK when executed within a server-side environment. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [Installation](#installation) 9 | - [Initialization](#initialization) 10 | - [Tokens](#tokens) 11 | - [Making Requests](#making-requests) 12 | - [Low-level Requests](#low-level-requests) 13 | - [Setting Headers](#setting-headers) 14 | - [Responses](#responses) 15 | - [Success](#success) 16 | - [Error](#error) 17 | - [Example App](#example-app) 18 | - [Changelog](#changelog) 19 | - [Community](#community) 20 | - [Additional Resources](#additional-resources) 21 | 22 | ## Getting Started 23 | 24 | ### Installation 25 | 26 | To begin using this SDK, you will first need to download it to your machine. We use [PyPi](https://pypi.python.org/pypi/dwollav2) to distribute this package from where you can automagically download it via [pip](https://pip.pypa.io/en/stable/installing/). 27 | 28 | ```shell 29 | $ pip install dwollav2 30 | ``` 31 | 32 | ### Initialization 33 | 34 | Before any API requests can be made, you must first determine which environment you will be using, as well as fetch the application key and secret. To fetch your application key and secret, please visit one of the following links: 35 | 36 | - Production: https://dashboard.dwolla.com/applications 37 | - Sandbox: https://dashboard-sandbox.dwolla.com/applications 38 | 39 | Finally, you can create an instance of `Client` with `key` and `secret` replaced with the application key and secret that you fetched from one of the aforementioned links, respectively. 40 | 41 | ```python 42 | client = dwollav2.Client( 43 | key = os.environ['DWOLLA_APP_KEY'], 44 | secret = os.environ['DWOLLA_APP_SECRET'], 45 | environment = 'sandbox', # defaults to 'production' 46 | requests = {'timeout': 0.001} 47 | ) 48 | ``` 49 | 50 | ##### Configure an `on_grant` callback (optional) 51 | 52 | An `on_grant` callback is useful for storing new tokens when they are granted. The `on_grant` 53 | callback is called with the `Token` that was just granted by the server. 54 | 55 | ```python 56 | client = dwollav2.Client( 57 | key = os.environ['DWOLLA_APP_KEY'], 58 | secret = os.environ['DWOLLA_APP_SECRET'], 59 | on_grant = lambda t: save(t) 60 | ) 61 | ``` 62 | 63 | It is highly recommended that you encrypt any token data you store. 64 | 65 | #### Tokens 66 | 67 | ##### Generating New Access Tokens 68 | 69 | Application access tokens are used to authenticate against the API on behalf of an application. Application tokens can be used to access resources in the API that either belong to the application itself (`webhooks`, `events`, `webhook-subscriptions`) or the Dwolla Account that owns the application (`accounts`, `customers`, `funding-sources`, etc.). Application tokens are obtained by using the [`client_credentials`](https://tools.ietf.org/html/rfc6749#section-4.4) OAuth grant type: 70 | 71 | ```python 72 | application_token = client.Auth.client() 73 | ``` 74 | 75 | _Application access tokens are short-lived: 1 hour. They do not include a `refresh_token`. When it expires, generate a new one using `client.Auth.client()`._ 76 | 77 | ##### Initializing Pre-Existing Tokens: 78 | 79 | The [Dwolla Sandbox Dashboard](https://dashboard-sandbox.dwolla.com/applications-legacy) allows you to generate tokens for your application. A `Token` can be initialized with the following attributes: 80 | 81 | ```python 82 | client.Token(access_token = '...', 83 | expires_in = 123) 84 | ``` 85 | 86 | ## Making Requests 87 | 88 | Once you've created a `Token`, currently, you can make low-level HTTP requests. 89 | 90 | ### Low-level Requests 91 | 92 | To make low-level HTTP requests, you can use the `get()`, `post()`, and `delete()` methods. These methods will return a `Response` object. 93 | 94 | #### `GET` 95 | 96 | ```python 97 | # GET api.dwolla.com/resource?foo=bar 98 | token.get('resource', foo = 'bar') 99 | 100 | # GET requests can also use objects as parameters 101 | # GET api.dwolla.com/resource?foo=bar 102 | token.get('resource', {'foo' = 'bar', 'baz' = 'foo'}) 103 | ``` 104 | 105 | #### `POST` 106 | 107 | ```python 108 | # POST api.dwolla.com/resource {"foo":"bar"} 109 | token.post('resource', foo = 'bar') 110 | 111 | # POST api.dwolla.com/resource multipart/form-data foo=... 112 | token.post('resource', foo = ('mclovin.jpg', open('mclovin.jpg', 'rb'), 'image/jpeg')) 113 | ``` 114 | 115 | #### `DELETE` 116 | 117 | ```python 118 | # DELETE api.dwolla.com/resource 119 | token.delete('resource') 120 | ``` 121 | 122 | #### Setting headers 123 | 124 | To set additional headers on a request you can pass a `dict` of headers as the 3rd argument. 125 | 126 | For example: 127 | 128 | ```python 129 | token.post('customers', { 'firstName': 'John', 'lastName': 'Doe', 'email': 'jd@doe.com' }, 130 | { 'Idempotency-Key': 'a52fcf63-0730-41c3-96e8-7147b5d1fb01' }) 131 | ``` 132 | 133 | #### Responses 134 | 135 | The following snippets demonstrate successful and errored responses from the Dwolla API. 136 | 137 | An errored response is returned when Dwolla's servers respond with a status code that is greater than or equal to 400, whereas a successful response is when Dwolla's servers respond with a 200-level status code. 138 | 139 | ##### Success 140 | 141 | ```python 142 | res = token.get('/') 143 | 144 | res.status 145 | # => 200 146 | 147 | res.headers 148 | # => {'server'=>'cloudflare-nginx', 'date'=>'Mon, 28 Mar 2016 15:30:23 GMT', 'content-type'=>'application/vnd.dwolla.v1.hal+json; charset=UTF-8', 'content-length'=>'150', 'connection'=>'close', 'set-cookie'=>'__cfduid=d9dcd0f586c166d36cbd45b992bdaa11b1459179023; expires=Tue, 28-Mar-17 15:30:23 GMT; path=/; domain=.dwolla.com; HttpOnly', 'x-request-id'=>'69a4e612-5dae-4c52-a6a0-2f921e34a88a', 'cf-ray'=>'28ac1f81875941e3-MSP'} 149 | 150 | res.body['_links']['events']['href'] 151 | # => 'https://api-sandbox.dwolla.com/events' 152 | ``` 153 | 154 | ##### Error 155 | 156 | If the server returns an error, a `dwollav2.Error` (or one of its subclasses) will be raised. 157 | `dwollav2.Error`s are similar to `Response`s. 158 | 159 | ```python 160 | try: 161 | token.get('/not-found') 162 | except dwollav2.NotFoundError as e: 163 | e.status 164 | # => 404 165 | 166 | e.headers 167 | # => {"server"=>"cloudflare-nginx", "date"=>"Mon, 28 Mar 2016 15:35:32 GMT", "content-type"=>"application/vnd.dwolla.v1.hal+json; profile=\"http://nocarrier.co.uk/profiles/vnd.error/\"; charset=UTF-8", "content-length"=>"69", "connection"=>"close", "set-cookie"=>"__cfduid=da1478bfdf3e56275cd8a6a741866ccce1459179332; expires=Tue, 28-Mar-17 15:35:32 GMT; path=/; domain=.dwolla.com; HttpOnly", "access-control-allow-origin"=>"*", "x-request-id"=>"667fca74-b53d-43db-bddd-50426a011881", "cf-ray"=>"28ac270abca64207-MSP"} 168 | 169 | e.body.code 170 | # => "NotFound" 171 | except dwollav2.Error: 172 | # ... 173 | ``` 174 | 175 | ###### `dwollav2.Error` subclasses: 176 | 177 | _See https://developers.dwolla.com/api-reference#errors for more info._ 178 | 179 | - `dwollav2.AccessDeniedError` 180 | - `dwollav2.InvalidCredentialsError` 181 | - `dwollav2.NotFoundError` 182 | - `dwollav2.BadRequestError` 183 | - `dwollav2.InvalidGrantError` 184 | - `dwollav2.RequestTimeoutError` 185 | - `dwollav2.ExpiredAccessTokenError` 186 | - `dwollav2.InvalidRequestError` 187 | - `dwollav2.ServerError` 188 | - `dwollav2.ForbiddenError` 189 | - `dwollav2.InvalidResourceStateError` 190 | - `dwollav2.TemporarilyUnavailableError` 191 | - `dwollav2.InvalidAccessTokenError` 192 | - `dwollav2.InvalidScopeError` 193 | - `dwollav2.UnauthorizedClientError` 194 | - `dwollav2.InvalidAccountStatusError` 195 | - `dwollav2.InvalidScopesError` 196 | - `dwollav2.UnsupportedGrantTypeError` 197 | - `dwollav2.InvalidApplicationStatusError` 198 | - `dwollav2.InvalidVersionError` 199 | - `dwollav2.UnsupportedResponseTypeError` 200 | - `dwollav2.InvalidClientError` 201 | - `dwollav2.MethodNotAllowedError` 202 | - `dwollav2.ValidationError` 203 | - `dwollav2.TooManyRequestsError` 204 | - `dwollav2.ConflictError` 205 | 206 | ### Example App 207 | 208 | Take a look at the 209 | [Sample Application](https://github.com/Dwolla/dwolla-v2-python/tree/main/sample_app) for examples 210 | on how to use this SDK to call the Dwolla API. Before you can begin using the app, however, 211 | you will need to specify a `DWOLLA_APP_KEY` and `DWOLLA_APP_SECRET` environment variable. 212 | 213 | ## Changelog 214 | 215 | - **2.3.0** 216 | - Remove hidden dependency on `simplejson`. Replace conditional `simplejson` import with explicit `DecimalEncoder` using standard library `json` module for consistent cross-environment behavior. Fixes [#55](https://github.com/Dwolla/dwolla-v2-python/issues/55). ([#56](https://github.com/Dwolla/dwolla-v2-python/pull/56) - Thanks [@robotadam](https://github.com/robotadam)!) 217 | - Update test suite from `unittest2` to standard library `unittest` for modern Python compatibility. ([#54](https://github.com/Dwolla/dwolla-v2-python/pull/54) - Thanks [@robotadam](https://github.com/robotadam)!) 218 | - Remove unused `future` dependency for cleaner dependency management. Fixes [#52](https://github.com/Dwolla/dwolla-v2-python/issues/52). ([#53](https://github.com/Dwolla/dwolla-v2-python/pull/53) Thanks [@robotadam](https://github.com/robotadam)!) 219 | - Add `UpdateCredentialsError` class for handling credential update scenarios in Open Banking integrations. Developers can now catch specific `UpdateCredentialsError` exceptions instead of generic `Error` when exchange sessions need re-authentication. Fixes [#50](https://github.com/Dwolla/dwolla-v2-python/issues/50) 220 | - **2.2.1** 221 | - Add extra check in URL's to ensure they are clean. [#36](https://github.com/Dwolla/dwolla-v2-python/pull/36). 222 | - **2.2.0** 223 | - Update JSON request bodies to serialize via `simplejson` so datatypes like `Decimal` still 224 | serialize like they did pre `2.0.0` 225 | - **2.1.0** 226 | - Do not share `requests.session()` across instances of `dwollav2.Client` 227 | - **2.0.0** 228 | - JSON request bodies now contain sorted keys to ensure the same request body for a given set of 229 | arguments, no matter the order they are passed to `dwolla.post`. This ensures the 230 | [`Idempotency-Key`][] header will work as intended without additional effort by developers. 231 | - **NOTE**: Because this change alters the formatting of JSON request bodies, we are releasing it 232 | as a major new version. The request body of a request made with `1.6.0` will not match the 233 | request body of the same request made in `2.0.0`. This will nullify the effect of the 234 | [`Idempotency-Key`][] header when upgrading, so please take this into account. 235 | If you have any questions please [reach out to us](https://discuss.dwolla.com/)! 236 | There are no other changes since `1.6.0`. 237 | - **1.6.0** Allow configuration of `requests` options on `dwollav2.Client`. 238 | - **1.5.0** Add integrations auth functionality 239 | - **1.4.0** ~~Pass kwargs from `get`, `post`, and `delete` methods to underlying requests methods.~~ (Removed in v1.6) 240 | - **1.3.0** Change token URLs, update dependencies. 241 | - **1.2.4** Create a new session for each Token. 242 | - **1.2.3** Check if IOBase when checking to see if something is a file. 243 | - **1.2.2** Strip domain from URLs provided to token.\* methods. 244 | - **1.2.1** Update sandbox URLs from uat => sandbox. 245 | - **1.2.0** Refer to Client id as key. 246 | - **1.1.8** Support `verified_account` and `dwolla_landing` auth flags 247 | - **1.1.7** Use session over connections for [performance improvement](http://docs.python-requests.org/en/master/user/advanced/#session-objects) ([#8](https://github.com/Dwolla/dwolla-v2-python/pull/8) - Thanks [@bfeeser](https://github.com/bfeeser)! 248 | - **1.1.5** Fix file upload bug when using with Python 2 ([#6](https://github.com/Dwolla/dwolla-v2-python/issues/6)) 249 | - **1.1.2** Add `TooManyRequestsError` and `ConflictError` 250 | - **1.1.1** Add MANIFEST.in 251 | - **1.1.0** Support per-request headers 252 | 253 | ## Community 254 | 255 | - If you have any feedback, please reach out to us on [our forums](https://discuss.dwolla.com/) or by [creating a GitHub issue](https://github.com/Dwolla/dwolla-v2-python/issues/new). 256 | - If you would like to contribute to this library, [bug reports](https://github.com/Dwolla/dwolla-v2-python/issues) and [pull requests](https://github.com/Dwolla/dwolla-v2-python/pulls) are always appreciated! 257 | - After checking out the repo, run `pip install -r requirements.txt` to install dependencies. Then, run `python setup.py` test to run the tests. 258 | - To install this gem onto your local machine, `run pip install -e .`. 259 | 260 | ## Docker 261 | 262 | If you prefer to use Docker to run dwolla-v2-python locally, a Dockerfile is included at the root directory. 263 | Follow these instructions from [Docker's website](https://docs.docker.com/build/hellobuild/) to create a Docker image from the Dockerfile, and run it. 264 | 265 | ## Additional Resources 266 | 267 | To learn more about Dwolla and how to integrate our product with your application, please consider visiting the following resources and becoming a member of our community! 268 | 269 | - [Dwolla](https://www.dwolla.com/) 270 | - [Dwolla Developers](https://developers.dwolla.com/) 271 | - [SDKs and Tools](https://developers.dwolla.com/sdks-tools) 272 | - [Dwolla SDK for C#](https://github.com/Dwolla/dwolla-v2-csharp) 273 | - [Dwolla SDK for Kotlin](https://github.com/Dwolla/dwolla-v2-kotlin) 274 | - [Dwolla SDK for Node](https://github.com/Dwolla/dwolla-v2-node) 275 | - [Dwolla SDK for PHP](https://github.com/Dwolla/dwolla-swagger-php) 276 | - [Dwolla SDK for Ruby](https://github.com/Dwolla/dwolla-v2-ruby) 277 | - [Developer Support Forum](https://discuss.dwolla.com/) 278 | 279 | [`idempotency-key`]: https://docs.dwolla.com/#idempotency-key 280 | --------------------------------------------------------------------------------