├── tests
├── __init__.py
├── http
│ ├── __init__.py
│ ├── test_bad_request_with_response_id.py
│ ├── test_pagination_scenario.py
│ └── test_pagination.py
├── model
│ ├── __init__.py
│ ├── generated
│ │ ├── __init__.py
│ │ ├── endpoint
│ │ │ ├── __init__.py
│ │ │ ├── test_session.py
│ │ │ ├── test_monetary_account_joint.py
│ │ │ ├── test_attachment_public.py
│ │ │ ├── test_monetary_account_bank.py
│ │ │ ├── test_avatar.py
│ │ │ ├── test_request_inquiry.py
│ │ │ ├── test_card_debit.py
│ │ │ └── test_payment.py
│ │ └── object
│ │ │ ├── __init__.py
│ │ │ └── test_notification_url.py
│ └── core
│ │ ├── test_oauth_authorization_uri.py
│ │ └── test_notification_filter.py
├── context
│ ├── __init__.py
│ ├── test_user_context.py
│ ├── test_psd2_context.py
│ └── test_api_context.py
├── assets
│ ├── vader.png
│ ├── NotificationUrlJsons
│ │ ├── ChatMessageAnnouncement.json
│ │ ├── MonetaryAccountBank.json
│ │ ├── ShareInviteBankResponse.json
│ │ ├── BunqMeTab.json
│ │ ├── Mutation.json
│ │ ├── ShareInviteBankInquiry.json
│ │ ├── RequestResponse.json
│ │ ├── RequestInquiry.json
│ │ ├── ScheduledPayment.json
│ │ └── MasterCardAction.json
│ └── ResponseJsons
│ │ └── MonetaryAccountJoint.json
├── README.md
├── config.py
└── bunq_test.py
├── VERSION
├── bunq
├── sdk
│ ├── __init__.py
│ ├── context
│ │ ├── __init__.py
│ │ ├── api_environment_type.py
│ │ ├── installation_context.py
│ │ ├── bunq_context.py
│ │ ├── user_context.py
│ │ └── session_context.py
│ ├── http
│ │ ├── __init__.py
│ │ ├── http_util.py
│ │ ├── bunq_response_raw.py
│ │ ├── bunq_response.py
│ │ ├── anonymous_api_client.py
│ │ └── pagination.py
│ ├── json
│ │ ├── __init__.py
│ │ ├── float_adapter.py
│ │ ├── api_environment_type_adapter.py
│ │ ├── date_time_adapter.py
│ │ ├── monetary_account_reference_adapter.py
│ │ ├── geolocation_adapter.py
│ │ ├── installation_adapter.py
│ │ ├── installation_context_adapter.py
│ │ ├── share_detail_adapter.py
│ │ ├── anchor_object_adapter.py
│ │ ├── pagination_adapter.py
│ │ └── session_server_adapter.py
│ ├── model
│ │ ├── __init__.py
│ │ ├── core
│ │ │ ├── __init__.py
│ │ │ ├── anchor_object_interface.py
│ │ │ ├── oauth_response_type.py
│ │ │ ├── oauth_grant_type.py
│ │ │ ├── id.py
│ │ │ ├── uuid.py
│ │ │ ├── public_key_server.py
│ │ │ ├── session_token.py
│ │ │ ├── notification_filter_url_user_internal.py
│ │ │ ├── notification_filter_push_user_internal.py
│ │ │ ├── notification_filter_url_monetary_account_internal.py
│ │ │ ├── payment_service_provider_credential_internal.py
│ │ │ ├── installation.py
│ │ │ ├── oauth_authorization_uri.py
│ │ │ ├── device_server_internal.py
│ │ │ ├── session_server.py
│ │ │ ├── oauth_access_token.py
│ │ │ └── bunq_model.py
│ │ └── generated
│ │ │ └── __init__.py
│ ├── util
│ │ ├── __init__.py
│ │ ├── type_alias.py
│ │ └── util.py
│ ├── exception
│ │ ├── __init__.py
│ │ ├── bad_request_exception.py
│ │ ├── forbidden_exception.py
│ │ ├── not_found_exception.py
│ │ ├── unauthorized_exception.py
│ │ ├── bunq_exception.py
│ │ ├── uncaught_exception_error.py
│ │ ├── method_not_allowed_exception.py
│ │ ├── please_contact_bunq_exception.py
│ │ ├── too_many_requests_exception.py
│ │ ├── unknown_api_error_exception.py
│ │ ├── api_exception.py
│ │ ├── EXCEPTIONS.md
│ │ └── exception_factory.py
│ └── security
│ │ ├── __init__.py
│ │ └── security.py
└── __init__.py
├── MANIFEST.in
├── .gitattributes
├── .gitmodules
├── requirements.txt
├── README.md
├── .idea
├── vcs.xml
├── inspectionProfiles
│ ├── profiles_settings.xml
│ └── Project_Default.xml
└── codeStyleSettings.xml
├── setup.cfg
├── run.py
├── .zappr.yaml
├── .github
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── slack-on-issue.yml
├── LICENSE.md
├── CONTRIBUTING.md
├── .gitignore
└── setup.py
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | 1.27.17.56
2 |
--------------------------------------------------------------------------------
/bunq/sdk/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/http/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/model/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/context/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/http/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/json/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/model/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/util/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/context/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/security/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/model/generated/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bunq/sdk/model/generated/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/model/generated/object/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include *.md
3 | include VERSION
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | bunq/sdk/model/generated/* linguist-generated=true
2 |
--------------------------------------------------------------------------------
/tests/assets/vader.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bunq/sdk_python/HEAD/tests/assets/vader.png
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "examples"]
2 | path = examples
3 | url = https://github.com/bunq/tinker_python.git
4 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/anchor_object_interface.py:
--------------------------------------------------------------------------------
1 | class AnchorObjectInterface:
2 | def is_all_field_none(self) -> None:
3 | pass
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aenum==2.2.4
2 | chardet==3.0.4
3 | pycryptodomex==3.9.8
4 | requests[socks]==2.24.0
5 | simplejson==3.17.2
6 | urllib3==1.25.10
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 📚 For full documentation about this sdk, visit [doc.bunq.com](https://doc.bunq.com/getting-started/tools/software-development-kits-sdks/python/usage).
2 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | 📚 For full API Test documentation, visit [doc.bunq.com](https://doc.bunq.com/getting-started/tools/software-development-kits-sdks/python/tests).
2 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/bad_request_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class BadRequestException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/forbidden_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class ForbiddenException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/not_found_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class NotFoundException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/unauthorized_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class UnauthorizedException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/util/type_alias.py:
--------------------------------------------------------------------------------
1 | from typing import Union, TypeVar
2 |
3 | JsonValue = Union[int, str, bool, float, bytes, list, dict, object, None]
4 |
5 | T = TypeVar('T')
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/bunq_exception.py:
--------------------------------------------------------------------------------
1 | class BunqException(Exception):
2 | def __init__(self, message: str) -> None:
3 | super(BunqException, self).__init__(message)
4 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/uncaught_exception_error.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class UncaughtExceptionError(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/method_not_allowed_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class MethodNotAllowedException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/please_contact_bunq_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class PleaseContactBunqException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/too_many_requests_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class TooManyRequestsException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/unknown_api_error_exception.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.api_exception import ApiException
2 |
3 |
4 | class UnknownApiErrorException(ApiException):
5 | pass
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | # This flag says that the code is written to work on both Python 2 and Python
3 | # 3. If at all possible, it is good practice to do this. If you cannot, you
4 | # will need to generate wheels for each Python version that you support.
5 | universal=0
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | import sys
3 |
4 | if len(sys.argv) != 2:
5 | print('Invalid argument count. Usage: python3 run.py examples/example_name.py')
6 |
7 | path = sys.argv[1]
8 | module_ = path.rstrip('.py').replace('/', '.')
9 | exec('import {}'.format(module_))
10 | exec('{}.run()'.format(module_))
11 |
--------------------------------------------------------------------------------
/.idea/codeStyleSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/oauth_response_type.py:
--------------------------------------------------------------------------------
1 | import aenum
2 |
3 |
4 | class OauthResponseType(aenum.AutoNumberEnum):
5 | """
6 | :type CODE: str
7 | :type response_type: str
8 | """
9 |
10 | CODE = 'code'
11 |
12 | def __init__(self, response_type: str) -> None:
13 | self._response_type = response_type
14 |
15 | @property
16 | def response_type(self) -> str:
17 | return self._response_type
18 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/oauth_grant_type.py:
--------------------------------------------------------------------------------
1 | import aenum
2 |
3 |
4 | class OauthGrantType(aenum.AutoNumberEnum):
5 | """
6 | :type AUTHORIZATION_CODE: str
7 | :type grant_type: str
8 | """
9 |
10 | AUTHORIZATION_CODE = 'authorization_code'
11 |
12 | def __init__(self, grant_type: str) -> None:
13 | self._grant_type = grant_type
14 |
15 | @property
16 | def grant_type(self) -> str:
17 | return self.grant_type
18 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/id.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.model.core.bunq_model import BunqModel
2 |
3 |
4 | class Id(BunqModel):
5 | """
6 | :type _id_: int
7 | """
8 |
9 | def __init__(self) -> None:
10 | self._id_ = None
11 |
12 | @property
13 | def id_(self) -> int:
14 | return self._id_
15 |
16 | def is_all_field_none(self) -> bool:
17 | if self.id_ is not None:
18 | return False
19 |
20 | return True
21 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/uuid.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.model.core.bunq_model import BunqModel
2 |
3 |
4 | class Uuid(BunqModel):
5 | """
6 | :type _uuid: str
7 | """
8 |
9 | def __init__(self) -> None:
10 | self._uuid = None
11 |
12 | @property
13 | def uuid(self) -> str:
14 | return self._uuid
15 |
16 | def is_all_field_none(self) -> bool:
17 | if self.uuid is not None:
18 | return False
19 |
20 | return True
21 |
--------------------------------------------------------------------------------
/bunq/sdk/http/http_util.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 |
4 | class HttpUtil:
5 | QUERY_FORMAT = '{}={}'
6 | QUERY_DELIMITER = '&'
7 |
8 | @classmethod
9 | def create_query_string(cls, all_parameter: Dict[str, str]):
10 | encoded_parameters = []
11 |
12 | for parameter, value in all_parameter.items():
13 | encoded_parameters.append(cls.QUERY_FORMAT.format(parameter, value))
14 |
15 | return cls.QUERY_DELIMITER.join(encoded_parameters)
16 |
--------------------------------------------------------------------------------
/.zappr.yaml:
--------------------------------------------------------------------------------
1 | commit:
2 | message:
3 | patterns:
4 | - '([A-Za-z0-9 ]+)\. (\(bunq\/sdk_python#[0-9]+\))'
5 | specification:
6 | title:
7 | minimum-length:
8 | enabled: true
9 | length: 8
10 | body:
11 | minimum-length:
12 | enabled: true
13 | length: 8
14 | contains-url: true
15 | contains-issue-number: true
16 | template:
17 | differs-from-body: true
18 | pull-request:
19 | labels:
20 | additional: true
21 | required:
22 | - Can be merged
23 |
--------------------------------------------------------------------------------
/bunq/sdk/context/api_environment_type.py:
--------------------------------------------------------------------------------
1 | import aenum
2 |
3 |
4 | class ApiEnvironmentType(aenum.AutoNumberEnum):
5 | """
6 | :type PRODUCTION: ApiEnvironmentType
7 | :type SANDBOX: ApiEnvironmentType
8 | :type uri_base: str
9 | """
10 |
11 | PRODUCTION = 'https://api.bunq.com/v1/'
12 | SANDBOX = 'https://public-api.sandbox.bunq.com/v1/'
13 |
14 | def __init__(self, uri_base: str) -> None:
15 | self._uri_base = uri_base
16 |
17 | @property
18 | def uri_base(self) -> str:
19 | return self._uri_base
20 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/public_key_server.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.model.core.bunq_model import BunqModel
2 |
3 |
4 | class PublicKeyServer(BunqModel):
5 | """
6 | :type _server_public_key: str
7 | """
8 |
9 | def __init__(self) -> None:
10 | self._server_public_key = None
11 |
12 | @property
13 | def server_public_key(self) -> str:
14 | return self._server_public_key
15 |
16 | def is_all_field_none(self) -> bool:
17 | if self.server_public_key is not None:
18 | return False
19 |
20 | return True
21 |
--------------------------------------------------------------------------------
/bunq/sdk/http/bunq_response_raw.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 |
4 | class BunqResponseRaw:
5 | """
6 | :type _body_bytes: bytes
7 | :type _headers: dict[str, str]
8 | """
9 |
10 | def __init__(self,
11 | body_bytes: bytes,
12 | headers: Dict[str, str]) -> None:
13 | self._body_bytes = body_bytes
14 | self._headers = headers
15 |
16 | @property
17 | def body_bytes(self) -> bytes:
18 | return self._body_bytes
19 |
20 | @property
21 | def headers(self) -> Dict[str, str]:
22 | return self._headers
23 |
--------------------------------------------------------------------------------
/bunq/sdk/json/float_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type
2 |
3 | from bunq.sdk.json import converter
4 |
5 |
6 | class FloatAdapter(converter.JsonAdapter):
7 | # Precision to round the floats to before outputting them
8 | _PRECISION_FLOAT = 2
9 |
10 | @classmethod
11 | def deserialize(cls,
12 | target_class: Type[float],
13 | string: str) -> float:
14 | _ = target_class
15 |
16 | return float(string)
17 |
18 | @classmethod
19 | def serialize(cls, number: float) -> str:
20 | return str(round(number, cls._PRECISION_FLOAT))
21 |
--------------------------------------------------------------------------------
/bunq/sdk/json/api_environment_type_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type
2 |
3 | from bunq.sdk.context.api_environment_type import ApiEnvironmentType
4 | from bunq.sdk.json import converter
5 |
6 |
7 | class ApiEnvironmentTypeAdapter(converter.JsonAdapter):
8 | @classmethod
9 | def deserialize(cls,
10 | target_class: Type[ApiEnvironmentType],
11 | name: str) -> ApiEnvironmentType:
12 | return ApiEnvironmentType[name]
13 |
14 | @classmethod
15 | def serialize(cls, api_environment_type: ApiEnvironmentType) -> str:
16 | return api_environment_type.name
17 |
--------------------------------------------------------------------------------
/bunq/sdk/json/date_time_adapter.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import Type
3 |
4 | from bunq.sdk.json import converter
5 |
6 |
7 | class DateTimeAdapter(converter.JsonAdapter):
8 | # bunq timestamp format
9 | _FORMAT_TIMESTAMP = '%Y-%m-%d %H:%M:%S.%f'
10 |
11 | @classmethod
12 | def deserialize(cls,
13 | target_class: Type[datetime.datetime],
14 | string: str) -> datetime.datetime:
15 | return target_class.strptime(string, cls._FORMAT_TIMESTAMP)
16 |
17 | @classmethod
18 | def serialize(cls, timestamp: datetime.datetime) -> str:
19 | return timestamp.strftime(cls._FORMAT_TIMESTAMP)
20 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/api_exception.py:
--------------------------------------------------------------------------------
1 | class ApiException(Exception):
2 | def __init__(self,
3 | message: str,
4 | response_code: int,
5 | response_id: str) -> None:
6 | self._response_id = response_id
7 | self._message = message
8 | self._response_code = response_code
9 |
10 | super(ApiException, self).__init__(message)
11 |
12 | @property
13 | def message(self) -> str:
14 | return self._message
15 |
16 | @property
17 | def response_code(self) -> int:
18 | return self._response_code
19 |
20 | @property
21 | def response_id(self) -> str:
22 | return self._response_id
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Steps to reproduce:
2 | 1.
3 |
4 | ## What should happen:
5 | 1.
6 |
7 | ## What happens:
8 | 1.
9 |
10 | ## Traceback
11 | [//]: # (If there is a traceback please share it in a quote! You can do this by pasting the traceback text, highlighting it and pressing the quote button.)
12 |
13 | ## SDK version and environment
14 | - Tested on [1.14.1](https://github.com/bunq/sdk_python/releases/tag/1.14.1)
15 | - [ ] Sandbox
16 | - [ ] Production
17 |
18 | ## Response id
19 | [//]: # (If this error has something to do with a request that fails, please provide the response id of the request.)
20 | - Response id:
21 |
22 | ## Extra info:
23 | [//]: # (Please provide any other relevant information here)
24 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | [//]: # (Thanks for opening this pull request! Before you proceed please make sure that you have an issue that explains what this pull request will do.
2 | Make sure that all your commits link to this issue e.g. "My commit. \(bunq/sdk_python#\)".
3 | If this pull request is changing files that are located in "bunq/sdk/model/generated" then this pull request will be closed as these files must/can only be changed on bunq's side.)
4 |
5 | ## This PR closes/fixes the following issues:
6 | [//]: # (If for some reason your pull request does not require a test case you can just mark this box as checked and explain why it does not require a test case.)
7 | - Closes bunq/sdk_python#
8 | - [ ] Tested
9 |
--------------------------------------------------------------------------------
/tests/context/test_user_context.py:
--------------------------------------------------------------------------------
1 | from tests.bunq_test import BunqSdkTestCase
2 |
3 | class TestUserContext(BunqSdkTestCase):
4 | """
5 | Tests:
6 | UserContext
7 | """
8 |
9 | @classmethod
10 | def setUpClass(cls):
11 | super().setUpClass()
12 | cls._API_CONTEXT = cls._get_api_context()
13 |
14 | def test_build_user_context(self):
15 | from bunq.sdk.context.user_context import UserContext
16 | user_context = UserContext(
17 | self._API_CONTEXT.session_context.user_id,
18 | self._API_CONTEXT.session_context.get_user_reference()
19 | )
20 | user_context.refresh_user_context()
21 |
22 | self.assertIsNotNone(user_context.user_id)
23 | self.assertIsNotNone(user_context.primary_monetary_account.id_)
24 |
--------------------------------------------------------------------------------
/tests/http/test_bad_request_with_response_id.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.context.bunq_context import BunqContext
2 | from bunq.sdk.exception.api_exception import ApiException
3 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject
4 | from tests.bunq_test import BunqSdkTestCase
5 |
6 |
7 | class TestPagination(BunqSdkTestCase):
8 | """
9 | Tests if the response id from a failed request can be retrieved
10 | successfully.
11 | """
12 |
13 | _INVALID_MONETARY_ACCOUNT_ID = 0
14 |
15 | def test_bad_request_with_response_id(self):
16 | BunqContext.load_api_context(self._get_api_context())
17 |
18 | with self.assertRaises(ApiException) as caught_exception:
19 | MonetaryAccountBankApiObject.get(self._INVALID_MONETARY_ACCOUNT_ID)
20 | self.assertIsNotNone(caught_exception.exception.response_id)
21 |
--------------------------------------------------------------------------------
/bunq/sdk/json/monetary_account_reference_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type, Dict
2 |
3 | from bunq.sdk.json import converter
4 | from bunq.sdk.model.generated.object_ import MonetaryAccountReference, LabelMonetaryAccountObject
5 |
6 |
7 | class MonetaryAccountReferenceAdapter(converter.JsonAdapter):
8 | @classmethod
9 | def deserialize(cls,
10 | target_class: Type[MonetaryAccountReference],
11 | obj: Dict) -> MonetaryAccountReference:
12 | label_monetary_account = converter.deserialize(LabelMonetaryAccountObject, obj)
13 |
14 | return target_class.create_from_label_monetary_account(label_monetary_account)
15 |
16 | @classmethod
17 | def serialize(cls, monetary_account_reference: MonetaryAccountReference) -> Dict:
18 | return converter.serialize(monetary_account_reference.pointer)
19 |
--------------------------------------------------------------------------------
/bunq/sdk/context/installation_context.py:
--------------------------------------------------------------------------------
1 | from Cryptodome.PublicKey import RSA
2 | from Cryptodome.PublicKey.RSA import RsaKey
3 |
4 |
5 | class InstallationContext:
6 | """
7 | :type _token: str
8 | :type _private_key_client: RSA.RsaKey
9 | :type _public_key_server: RSA.RsaKey
10 | """
11 |
12 | def __init__(self,
13 | token: str,
14 | private_key_client: RsaKey,
15 | public_key_server: RsaKey) -> None:
16 | self._token = token
17 | self._private_key_client = private_key_client
18 | self._public_key_server = public_key_server
19 |
20 | @property
21 | def token(self) -> str:
22 | return self._token
23 |
24 | @property
25 | def private_key_client(self) -> RsaKey:
26 | return self._private_key_client
27 |
28 | @property
29 | def public_key_server(self) -> RsaKey:
30 | return self._public_key_server
31 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017 bunq b.v.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_session.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from bunq.sdk.context.bunq_context import BunqContext
4 | from bunq.sdk.model.generated.endpoint import SessionApiObject
5 | from tests.bunq_test import BunqSdkTestCase
6 |
7 |
8 | class TestSession(BunqSdkTestCase):
9 | """
10 | Tests:
11 | SessionApiObject
12 | """
13 |
14 | _SESSION_ID = 0
15 | _BUNQ_CONFIG_FILE = "bunq-test.conf"
16 | _DEVICE_DESCRIPTION = 'Python test device'
17 |
18 | def test_session_delete(self):
19 | """
20 | Tests the deletion and resetting of the current active session
21 |
22 | This test has no assertion as of its testing to see if the code runs
23 | without errors.
24 |
25 | Notes
26 | -----
27 | time.sleep() is needed as of you can only make 1 POST call to
28 | Session endpoint per second.
29 | """
30 |
31 | SessionApiObject.delete(self._SESSION_ID)
32 | time.sleep(2)
33 | BunqContext.api_context().reset_session()
34 | BunqContext.api_context().save(self._BUNQ_CONFIG_FILE)
35 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/session_token.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.model.core.bunq_model import BunqModel
2 |
3 |
4 | class SessionToken(BunqModel):
5 | """
6 | :type _id_: int
7 | :type _created: str
8 | :type _updated: str
9 | :type _token: str
10 | """
11 |
12 | def __init__(self) -> None:
13 | self._id_ = None
14 | self._created = None
15 | self._updated = None
16 | self._token = None
17 |
18 | @property
19 | def id_(self) -> int:
20 | return self._id_
21 |
22 | @property
23 | def created(self) -> str:
24 | return self._created
25 |
26 | @property
27 | def updated(self) -> str:
28 | return self._updated
29 |
30 | @property
31 | def token(self) -> str:
32 | return self._token
33 |
34 | def is_all_field_none(self) -> bool:
35 | if self.id_ is not None:
36 | return False
37 |
38 | if self.created is not None:
39 | return False
40 |
41 | if self.updated is not None:
42 | return False
43 |
44 | if self.token is not None:
45 | return False
46 |
47 | return True
48 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/ChatMessageAnnouncement.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "BUNQME_TAB",
5 | "event_type": "BUNQME_TAB_CREATED",
6 | "object": {
7 | "ChatMessageAnnouncement": {
8 | "id": 30,
9 | "created": "2017-09-05 12:00:00.912674",
10 | "updated": "2017-09-05 12:00:00.912674",
11 | "conversation_id": 3,
12 | "content": {
13 | "ChatMessageContentText": {
14 | "text": "Hey! You have failed to pay more than five direct debits over the past 30 days. Although such things can happen, please keep in mind that we might be forced to take measures if we get a lot of complaints about it. To help prevent this we summarised some information which might prove useful.\n\nRead more here. https:\/\/www.bunq.com\/en\/direct-debits\/\n\nPlease let us know if you have any further questions, we would love to help you out!"
15 | }
16 | },
17 | "client_message_uuid": null
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/model/core/test_oauth_authorization_uri.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.context.bunq_context import BunqContext
2 | from bunq.sdk.model.core.oauth_authorization_uri import OauthAuthorizationUri
3 | from bunq.sdk.model.core.oauth_response_type import OauthResponseType
4 | from bunq.sdk.model.generated.endpoint import OauthClient
5 | from tests.bunq_test import BunqSdkTestCase
6 |
7 |
8 | class TestOauthAuthorizationUri(BunqSdkTestCase):
9 | _TEST_EXPECT_URI = 'https://oauth.sandbox.bunq.com/auth?redirect_uri=redirecturi&response_type=code&state=state'
10 | _TEST_REDIRECT_URI = 'redirecturi'
11 | _TEST_STATUS = 'status'
12 | _TEST_STATE = 'state'
13 |
14 | @classmethod
15 | def setUpClass(cls) -> None:
16 | BunqContext.load_api_context(cls._get_api_context())
17 |
18 | def test_oauth_authorization_uri_create(self) -> None:
19 | uri = OauthAuthorizationUri.create(
20 | OauthResponseType(OauthResponseType.CODE),
21 | self._TEST_REDIRECT_URI,
22 | OauthClient(self._TEST_STATUS),
23 | self._TEST_STATE
24 | ).get_authorization_uri()
25 |
26 | self.assertEqual(self._TEST_EXPECT_URI, uri)
27 |
--------------------------------------------------------------------------------
/bunq/sdk/http/bunq_response.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Dict, Generic
4 |
5 | from bunq import Pagination
6 | from bunq.sdk.util.type_alias import T
7 |
8 |
9 | class BunqResponse(Generic[T]):
10 | """
11 | :type _value: T
12 | :type _headers: dict[str, str]
13 | :type _pagination: Pagination|None
14 | """
15 |
16 | def __init__(self,
17 | value: T,
18 | headers: Dict[str, str],
19 | pagination: Pagination = None) -> None:
20 | self._value = value
21 | self._headers = headers
22 | self._pagination = pagination
23 |
24 | @property
25 | def value(self) -> T:
26 | return self._value
27 |
28 | @property
29 | def headers(self) -> Dict[str, str]:
30 | return self._headers
31 |
32 | @property
33 | def pagination(self) -> Pagination:
34 | return self._pagination
35 |
36 | @classmethod
37 | def cast_from_bunq_response(cls, bunq_response: BunqResponse) -> BunqResponse:
38 | return cls(
39 | bunq_response.value,
40 | bunq_response.headers,
41 | bunq_response.pagination
42 | )
43 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_monetary_account_joint.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from bunq.sdk.model.generated.endpoint import MonetaryAccountJointApiObject
4 | from tests.bunq_test import BunqSdkTestCase
5 |
6 |
7 | class TestMonetaryAccountJoint(BunqSdkTestCase):
8 | """
9 | Tests:
10 | - MonetaryAccountJointApiObject
11 | - CoOwner
12 | """
13 |
14 | _BASE_PATH_JSON_MODEL = '../../../assets/ResponseJsons'
15 | _MONETARY_ACCOUNT_JOINT_JSON = '/MonetaryAccountJoint.json'
16 | _FILE_MODE_READ = 'r'
17 |
18 | @classmethod
19 | def setUpClass(cls):
20 | pass
21 |
22 | def setUp(self):
23 | pass
24 |
25 | def test_monetary_account_joint_parser(self):
26 | base_path = os.path.dirname(__file__)
27 | file_path = os.path.abspath(
28 | os.path.join(base_path, self._BASE_PATH_JSON_MODEL + self._MONETARY_ACCOUNT_JOINT_JSON)
29 | )
30 |
31 | with open(file_path, self._FILE_MODE_READ) as f:
32 | json_string = f.read()
33 |
34 | joint_account = MonetaryAccountJointApiObject.from_json(json_string)
35 |
36 | self.assertIsInstance(joint_account, MonetaryAccountJointApiObject)
37 | self.assertIsNotNone(joint_account)
38 | self.assertIsNotNone(joint_account.all_co_owner)
39 |
40 | for co_owner in joint_account.all_co_owner:
41 | self.assertIsNotNone(co_owner.alias)
42 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_attachment_public.py:
--------------------------------------------------------------------------------
1 | from typing import AnyStr
2 |
3 | from bunq.sdk.http.api_client import ApiClient
4 | from bunq.sdk.model.generated.endpoint import AttachmentPublicContentApiObject, AttachmentPublicApiObject
5 | from tests.bunq_test import BunqSdkTestCase
6 |
7 |
8 | class TestAttachmentPublicApiObject(BunqSdkTestCase):
9 | """
10 | Tests:
11 | AttachmentPublicApiObject
12 | AttachmentPublicContentApiObject
13 | """
14 |
15 | def test_file_upload_and_retrieval(self):
16 | """
17 | Tests uploading an attachment, retrieves it and compare them to see
18 | if the uploaded attachment is indeed the attachment we are getting
19 | back.
20 | """
21 |
22 | custom_headers = {
23 | ApiClient.HEADER_CONTENT_TYPE: self._CONTENT_TYPE,
24 | ApiClient.HEADER_ATTACHMENT_DESCRIPTION:
25 | self._ATTACHMENT_DESCRIPTION,
26 | }
27 |
28 | attachment_uuid = AttachmentPublicApiObject.create(self.attachment_contents, custom_headers).value
29 | contents_from_response = AttachmentPublicContentApiObject.list(attachment_uuid).value
30 |
31 | self.assertEqual(self.attachment_contents, contents_from_response)
32 |
33 | @property
34 | def attachment_contents(self) -> AnyStr:
35 | with open(self._PATH_ATTACHMENT + self._ATTACHMENT_PATH_IN, self._READ_BYTES) as f:
36 | return f.read()
37 |
--------------------------------------------------------------------------------
/bunq/sdk/json/geolocation_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type, Dict
2 |
3 | from bunq.sdk.json import converter
4 | from bunq.sdk.model.generated.object_ import GeolocationObject
5 |
6 |
7 | class GeolocationAdapter(converter.JsonAdapter):
8 | # Field constants
9 | _FIELD_LATITUDE = 'latitude'
10 | _FIELD_LONGITUDE = 'longitude'
11 | _FIELD_ALTITUDE = 'altitude'
12 | _FIELD_RADIUS = 'radius'
13 |
14 | @classmethod
15 | def can_deserialize(cls) -> bool:
16 | return False
17 |
18 | @classmethod
19 | def deserialize(cls,
20 | target_class: Type[float],
21 | obj: Dict) -> None:
22 | """
23 |
24 | :raise: NotImplementedError
25 | """
26 |
27 | raise NotImplementedError()
28 |
29 | @classmethod
30 | def serialize(cls, geolocation: GeolocationObject) -> Dict:
31 | obj = {}
32 |
33 | cls.add_if_not_none(obj, cls._FIELD_LATITUDE, geolocation.latitude)
34 | cls.add_if_not_none(obj, cls._FIELD_LONGITUDE, geolocation.longitude)
35 | cls.add_if_not_none(obj, cls._FIELD_ALTITUDE, geolocation.altitude)
36 | cls.add_if_not_none(obj, cls._FIELD_RADIUS, geolocation.radius)
37 |
38 | return obj
39 |
40 | @classmethod
41 | def add_if_not_none(cls,
42 | dict_: Dict[str, str],
43 | key: str,
44 | value: float) -> None:
45 | if value is not None:
46 | dict_[key] = str(value)
47 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ### How to contribute to the bunq Python SDK 😎
2 |
3 | #### Want to add a new amazing feature to our SDK? 🚀
4 | - First let’s discuss the feature that you would like to add. [Open a new issue](https://github.com/bunq/sdk_python/issues/new), describe the feature and explain why you think it should be added.
5 | - Once we agree on the new feature, open a new GitHub pull request and include all the relevant information to get your code approved!
6 |
7 | #### Did you find a bug? 🐛
8 | - Before opening a new issue check if the bug hasn't already been reported by searching on GitHub under [issues](https://github.com/bunq/sdk_python/issues).
9 | - If it hasn't already been reported you can [open a new issue](https://github.com/bunq/sdk_python/issues/new). Make sure you include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behaviour that is not occurring.
10 | - If you wrote a patch that fixes a bug, open a new GitHub pull request and make sure to clearly describe the problem and your awesome solution.
11 |
12 | #### Do you have questions about the source code?
13 | - You are welcome to ask whatever you want about the bunq Python SDK on [Together](https://together.bunq.com) 🎤
14 |
15 | #### Are you looking for a challenging new adventure?
16 | - We're always looking for the best developers to come and join us at the Bank of the Free! 🌈 [Check out our vacancies](https://www.bunq.com/en/jobs)!
17 |
18 | Thanks, and have fun! 💪
19 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/notification_filter_url_user_internal.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import List, Dict
4 |
5 | from bunq.sdk.http.api_client import ApiClient
6 | from bunq.sdk.http.bunq_response import BunqResponse
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import NotificationFilterUrlUser
9 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl
10 |
11 |
12 | class NotificationFilterUrlUserInternal(NotificationFilterUrlUser):
13 | @classmethod
14 | def create_with_list_response(cls,
15 | all_notification_filter: List[NotificationFilterUrl] = None,
16 | custom_headers: Dict[str, str] = None
17 | ) -> BunqResponse[List[NotificationFilterUrl]]:
18 | if all_notification_filter is None:
19 | all_notification_filter = []
20 |
21 | if custom_headers is None:
22 | custom_headers = {}
23 |
24 | request_map = {
25 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter
26 | }
27 | request_map_string = converter.class_to_json(request_map)
28 | request_map_string = cls._remove_field_for_request(request_map_string)
29 |
30 | api_client = ApiClient(cls._get_api_context())
31 | request_bytes = request_map_string.encode()
32 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id())
33 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers)
34 |
35 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET)
36 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/notification_filter_push_user_internal.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import List, Dict
4 |
5 | from bunq.sdk.http.api_client import ApiClient
6 | from bunq.sdk.http.bunq_response import BunqResponse
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import NotificationFilterPushUser
9 | from bunq.sdk.model.generated.object_ import NotificationFilterPush, NotificationFilterUrl
10 |
11 |
12 | class NotificationFilterPushUserInternal(NotificationFilterPushUser):
13 | @classmethod
14 | def create_with_list_response(cls,
15 | all_notification_filter: List[NotificationFilterPush] = None,
16 | custom_headers: Dict[str, str] = None
17 | ) -> BunqResponse[List[NotificationFilterPush]]:
18 | if all_notification_filter is None:
19 | all_notification_filter = []
20 |
21 | if custom_headers is None:
22 | custom_headers = {}
23 |
24 | request_map = {
25 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter
26 | }
27 | request_map_string = converter.class_to_json(request_map)
28 | request_map_string = cls._remove_field_for_request(request_map_string)
29 |
30 | api_client = ApiClient(cls._get_api_context())
31 | request_bytes = request_map_string.encode()
32 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id())
33 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers)
34 |
35 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET)
36 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_monetary_account_bank.py:
--------------------------------------------------------------------------------
1 | try:
2 | from secrets import token_hex
3 | except ImportError:
4 | from os import urandom
5 |
6 |
7 | def token_hex():
8 | """ Function to replace import for Python < 3.6. """
9 | return urandom(16).hex()
10 |
11 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject
12 | from tests.bunq_test import BunqSdkTestCase
13 |
14 |
15 | class TestMonetaryAccount(BunqSdkTestCase):
16 | """
17 | Tests:
18 | MonetaryAccountBank
19 | """
20 |
21 | _STATUS = 'CANCELLED'
22 | _SUB_STATUS = 'REDEMPTION_VOLUNTARY'
23 | _REASON = 'OTHER'
24 | _REASON_DESCRIPTION = 'Because this is a test'
25 | _CURRENCY = 'EUR'
26 | _MONETARY_ACCOUNT_PREFIX = 'Python_test'
27 |
28 | def test_create_new_monetary_account(self):
29 | """
30 | Tests the creation of a new monetary account. This account will be
31 | deleted after test exited with code 0.
32 |
33 | This test has no assertion as of its testing to see if the code runs
34 | without errors
35 | """
36 |
37 | monetary_account_id = MonetaryAccountBankApiObject.create(
38 | self._CURRENCY,
39 | self._MONETARY_ACCOUNT_PREFIX + token_hex()
40 | ).value
41 |
42 | MonetaryAccountBankApiObject.update(monetary_account_id,
43 | status=self._STATUS,
44 | sub_status=self._SUB_STATUS,
45 | reason=self._REASON,
46 | reason_description=self._REASON_DESCRIPTION
47 | )
48 |
--------------------------------------------------------------------------------
/bunq/sdk/context/bunq_context.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.context.api_context import ApiContext
2 | from bunq.sdk.context.user_context import UserContext
3 | from bunq.sdk.exception.bunq_exception import BunqException
4 |
5 |
6 | class BunqContext:
7 | _ERROR_CLASS_SHOULD_NOT_BE_INITIALIZED = 'This class should not be instantiated'
8 | _ERROR_API_CONTEXT_HAS_NOT_BEEN_LOADED = 'ApiContext has not been loaded. Please load ApiContext in BunqContext'
9 | _ERROR_USER_CONTEXT_HAS_NOT_BEEN_LOADED = 'UserContext has not been loaded, please load this by loading ApiContext.'
10 |
11 | _api_context = None
12 | _user_context = None
13 |
14 | def __init__(self) -> None:
15 | raise TypeError(self._ERROR_CLASS_SHOULD_NOT_BE_INITIALIZED)
16 |
17 | @classmethod
18 | def load_api_context(cls, api_context: ApiContext) -> None:
19 | cls._api_context = api_context
20 | cls._user_context = UserContext(
21 | api_context.session_context.user_id,
22 | api_context.session_context.get_user_reference()
23 | )
24 | cls._user_context.init_main_monetary_account()
25 |
26 | @classmethod
27 | def api_context(cls) -> ApiContext:
28 | if cls._api_context is not None:
29 | return cls._api_context
30 |
31 | raise BunqException(cls._ERROR_API_CONTEXT_HAS_NOT_BEEN_LOADED)
32 |
33 | @classmethod
34 | def user_context(cls) -> UserContext:
35 | if cls._user_context is not None:
36 | return cls._user_context
37 |
38 | raise BunqException(cls._ERROR_USER_CONTEXT_HAS_NOT_BEEN_LOADED)
39 |
40 | @classmethod
41 | def update_api_context(cls, api_context: ApiContext) -> None:
42 | cls._api_context = api_context
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/pycharm
3 |
4 | ### PyCharm ###
5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
7 |
8 | # User-specific stuff:
9 | .idea
10 |
11 | # CMake
12 | cmake-build-debug/
13 |
14 | ## File-based project format:
15 | *.iws
16 |
17 | ## Plugin-specific files:
18 |
19 | # IntelliJ
20 | /out/
21 |
22 | # mpeltonen/sbt-idea plugin
23 | .idea_modules/
24 |
25 | # JIRA plugin
26 | atlassian-ide-plugin.xml
27 |
28 | # Cursive Clojure plugin
29 | .idea/replstate.xml
30 |
31 | # Crashlytics plugin (for Android Studio and IntelliJ)
32 | com_crashlytics_export_strings.xml
33 | crashlytics.properties
34 | crashlytics-build.properties
35 | fabric.properties
36 |
37 | ### PyCharm Patch ###
38 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
39 |
40 | *.iml
41 | modules.xml
42 | .idea/misc.xml
43 | *.ipr
44 |
45 | # Sonarlint plugin
46 | .idea/sonarlint
47 |
48 | # End of https://www.gitignore.io/api/pycharm
49 |
50 | # Python specific
51 | /.eggs
52 | /build
53 | /dist
54 | /MANIFEST
55 | /stripe.egg-info
56 | /bunq_env
57 | /.vscode
58 | .python-version
59 | *.pyc
60 | *.egg
61 | *.class
62 |
63 | # Unit test / coverage reports
64 | .tox/
65 | .coverage
66 | .cache
67 | nosetests.xml
68 | coverage.xml
69 | htmlcov/
70 |
71 | # bunq specific
72 | bunq.conf
73 | bunq-test.conf
74 | **/tmp
75 | config.json
76 | connectQr.png
77 | .DS_Store
78 | bunq_sdk.egg-info
79 | .idea/codeStyles/
80 | venv
81 |
82 | tests/bunq-oauth-psd2-test.conf
83 | tests/bunq-psd2-test.conf
84 | tests/key.pem
85 | tests/certificate.pem
86 |
87 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_avatar.py:
--------------------------------------------------------------------------------
1 | from typing import AnyStr
2 |
3 | from bunq.sdk.http.api_client import ApiClient
4 | from bunq.sdk.model.generated.endpoint import AttachmentPublicApiObject
5 | from bunq.sdk.model.generated.endpoint import AttachmentPublicContentApiObject
6 | from bunq.sdk.model.generated.endpoint import AvatarApiObject
7 | from tests.bunq_test import BunqSdkTestCase
8 |
9 |
10 | class TestAvatar(BunqSdkTestCase):
11 | """
12 | Tests:
13 | AvatarApiObject
14 | AttachmentPublicApiObject
15 | AttachmentPublicContentApiObject
16 | """
17 |
18 | def test_avatar_creation(self):
19 | """
20 | Tests the creation of an avatar by uploading a picture via
21 | AttachmentPublicApiObject and setting it as avatar via the uuid
22 | """
23 |
24 | custom_header = {
25 | ApiClient.HEADER_ATTACHMENT_DESCRIPTION:
26 | self._ATTACHMENT_DESCRIPTION,
27 | ApiClient.HEADER_CONTENT_TYPE: self._CONTENT_TYPE
28 | }
29 | attachment_public_uuid = AttachmentPublicApiObject.create(self.attachment_contents, custom_header).value
30 | avatar_uuid = AvatarApiObject.create(attachment_public_uuid).value
31 | attachment_uuid_after = AvatarApiObject.get(avatar_uuid).value.image[self._FIRST_INDEX].attachment_public_uuid
32 | file_contents_received = AttachmentPublicContentApiObject.list(attachment_uuid_after).value
33 |
34 | self.assertEqual(self.attachment_contents, file_contents_received)
35 |
36 | @property
37 | def attachment_contents(self) -> AnyStr:
38 | with open(self._PATH_ATTACHMENT + self._ATTACHMENT_PATH_IN, self._READ_BYTES) as file:
39 | return file.read()
40 |
--------------------------------------------------------------------------------
/.github/workflows/slack-on-issue.yml:
--------------------------------------------------------------------------------
1 | name: Notify Slack on New Issue or PR
2 |
3 | on:
4 | issues:
5 | types: [opened]
6 | pull_request:
7 | types: [opened]
8 |
9 | jobs:
10 | notify:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Send Slack notification
14 | run: |
15 | if [[ "${{ github.event_name }}" == "issues" ]]; then
16 | TYPE="Issue"
17 | TITLE="${{ github.event.issue.title }}"
18 | URL="${{ github.event.issue.html_url }}"
19 | USER="${{ github.event.issue.user.login }}"
20 | else
21 | TYPE="Pull Request"
22 | TITLE="${{ github.event.pull_request.title }}"
23 | URL="${{ github.event.pull_request.html_url }}"
24 | USER="${{ github.event.pull_request.user.login }}"
25 | fi
26 |
27 | PAYLOAD=$(jq -n \
28 | --arg type "$TYPE" \
29 | --arg title "$TITLE" \
30 | --arg url "$URL" \
31 | --arg user "$USER" \
32 | '{
33 | text: "*New GitHub \($type)* :sparkles:",
34 | attachments: [
35 | {
36 | color: "#36a64f",
37 | title: $title,
38 | title_link: $url,
39 | fields: [
40 | {
41 | title: "Author",
42 | value: $user,
43 | short: true
44 | }
45 | ]
46 | }
47 | ]
48 | }')
49 |
50 | curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" $SLACK_WEBHOOK_URL
51 | env:
52 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
53 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/notification_filter_url_monetary_account_internal.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import List, Dict
4 |
5 | from bunq.sdk.http.api_client import ApiClient
6 | from bunq.sdk.http.bunq_response import BunqResponse
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import NotificationFilterUrlMonetaryAccount
9 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl
10 |
11 |
12 | class NotificationFilterUrlMonetaryAccountInternal(NotificationFilterUrlMonetaryAccount):
13 | @classmethod
14 | def create_with_list_response(cls,
15 | monetary_account_id: int = None,
16 | all_notification_filter: List[NotificationFilterUrl] = None,
17 | custom_headers: Dict[str, str] = None
18 | ) -> BunqResponse[List[NotificationFilterUrl]]:
19 | if all_notification_filter is None:
20 | all_notification_filter = []
21 |
22 | if custom_headers is None:
23 | custom_headers = {}
24 |
25 | request_map = {
26 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter
27 | }
28 | request_map_string = converter.class_to_json(request_map)
29 | request_map_string = cls._remove_field_for_request(request_map_string)
30 |
31 | api_client = ApiClient(cls._get_api_context())
32 | request_bytes = request_map_string.encode()
33 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id(),
34 | cls._determine_monetary_account_id(monetary_account_id))
35 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers)
36 |
37 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET)
38 |
--------------------------------------------------------------------------------
/bunq/sdk/util/util.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import socket
5 |
6 | import requests
7 |
8 | from bunq.sdk.context.api_context import ApiContext, ApiEnvironmentType
9 | from bunq.sdk.exception.bunq_exception import BunqException
10 | from bunq.sdk.http.api_client import ApiClient
11 | from bunq.sdk.model.generated.endpoint import SandboxUserPersonApiObject
12 |
13 | __UNIQUE_REQUEST_ID = "uniqueness-is-required"
14 | __FIELD_API_KEY = "ApiKey"
15 | __INDEX_FIRST = 0
16 | __FIELD_RESPONSE = "Response"
17 | __ENDPOINT_SANDBOX_USER_PERSON = "sandbox-user-person"
18 |
19 | # Error constants
20 | _ERROR_COULD_NOT_CREATE_NEW_SANDBOX_USER = "Could not create new sandbox user."
21 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null'
22 |
23 |
24 | def automatic_sandbox_install() -> ApiContext:
25 | sandbox_user = __generate_new_sandbox_user()
26 |
27 | return ApiContext.create(ApiEnvironmentType.SANDBOX, sandbox_user.api_key, socket.gethostname())
28 |
29 |
30 | def __generate_new_sandbox_user() -> SandboxUserPersonApiObject:
31 | url = ApiEnvironmentType.SANDBOX.uri_base + __ENDPOINT_SANDBOX_USER_PERSON
32 |
33 | headers = {
34 | ApiClient.HEADER_REQUEST_ID: __UNIQUE_REQUEST_ID,
35 | ApiClient.HEADER_CACHE_CONTROL: ApiClient.CACHE_CONTROL_NONE,
36 | ApiClient.HEADER_GEOLOCATION: ApiClient.GEOLOCATION_ZERO,
37 | ApiClient.HEADER_LANGUAGE: ApiClient.LANGUAGE_EN_US,
38 | ApiClient.HEADER_REGION: ApiClient.REGION_NL_NL,
39 | }
40 |
41 | response = requests.request(ApiClient.METHOD_POST, url, headers=headers)
42 |
43 | if response.status_code is ApiClient.STATUS_CODE_OK:
44 | response_json = json.loads(response.text)
45 |
46 | return SandboxUserPersonApiObject.from_json(json.dumps(response_json[__FIELD_RESPONSE][__INDEX_FIRST][__FIELD_API_KEY]))
47 |
48 | raise BunqException(_ERROR_COULD_NOT_CREATE_NEW_SANDBOX_USER)
49 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_request_inquiry.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.model.generated.endpoint import RequestInquiryApiObject
2 | from bunq.sdk.model.generated.endpoint import RequestResponseApiObject
3 | from bunq.sdk.model.generated.object_ import AmountObject
4 | from tests.bunq_test import BunqSdkTestCase
5 |
6 |
7 | class TestRequestEnquiry(BunqSdkTestCase):
8 | """
9 | Tests:
10 | RequestInquiryApiObject
11 | RequestResponseApiObject
12 | """
13 |
14 | _REQUEST_AMOUNT_EUR = '0.01'
15 | _REQUEST_CURRENCY = 'EUR'
16 | _DESCRIPTION = 'Python unit test request'
17 | _FIELD_STATUS = 'status'
18 | _STATUS_ACCEPTED = 'ACCEPTED'
19 | _STATUS_PENDING = 'PENDING'
20 |
21 | def test_sending_and_accepting_request(self):
22 | """
23 | Tests sending a request from monetary account 1 to monetary account 2
24 | and accepting this request
25 |
26 | This test has no assertion as of its testing to see if the code runs
27 | without errors
28 | """
29 |
30 | self.send_request()
31 |
32 | url_params_count_only_expected = {
33 | self._FIELD_STATUS: self._STATUS_PENDING
34 | }
35 |
36 | request_response_id = RequestResponseApiObject.list(self._second_monetary_account.id_, url_params_count_only_expected).value[self._FIRST_INDEX].id_
37 |
38 | self.accept_request(request_response_id)
39 |
40 | def send_request(self) -> None:
41 | RequestInquiryApiObject.create(
42 | AmountObject(self._REQUEST_AMOUNT_EUR, self._REQUEST_CURRENCY),
43 | self._get_alias_second_account(),
44 | self._DESCRIPTION,
45 | False
46 | )
47 |
48 | def accept_request(self, response_id: int) -> None:
49 | RequestResponseApiObject.update(
50 | response_id,
51 | self._second_monetary_account.id_,
52 | None,
53 | self._STATUS_ACCEPTED
54 | )
--------------------------------------------------------------------------------
/bunq/sdk/model/core/payment_service_provider_credential_internal.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import typing
5 |
6 | from bunq.sdk.http.api_client import ApiClient
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import PaymentServiceProviderCredentialApiObject, UserCredentialPasswordIpApiObject
9 |
10 | if typing.TYPE_CHECKING:
11 | from bunq.sdk.context.api_context import ApiContext
12 |
13 |
14 | class PaymentServiceProviderCredentialInternal(PaymentServiceProviderCredentialApiObject):
15 | @classmethod
16 | def create_with_api_context(cls,
17 | client_payment_service_provider_certificate: str,
18 | client_payment_service_provider_certificate_chain: str,
19 | client_public_key_signature: str,
20 | api_context: ApiContext,
21 | all_custom_header=None) -> UserCredentialPasswordIpApiObject:
22 | request_map = {
23 | cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE: client_payment_service_provider_certificate,
24 | cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE_CHAIN: client_payment_service_provider_certificate_chain,
25 | cls.FIELD_CLIENT_PUBLIC_KEY_SIGNATURE: client_public_key_signature
26 | }
27 |
28 | if all_custom_header is None:
29 | all_custom_header = {}
30 |
31 | api_client = ApiClient(api_context)
32 | request_bytes = converter.class_to_json(request_map).encode()
33 | endpoint_url = cls._ENDPOINT_URL_CREATE
34 | response_raw = api_client.post(endpoint_url, request_bytes, all_custom_header)
35 |
36 | response_body = converter.json_to_class(dict, response_raw.body_bytes.decode())
37 | response_body_dict = converter.deserialize(cls, response_body[cls._FIELD_RESPONSE])[0]
38 |
39 | return UserCredentialPasswordIpApiObject.from_json(json.dumps(response_body_dict[cls._OBJECT_TYPE_GET]))
40 |
--------------------------------------------------------------------------------
/bunq/sdk/json/installation_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type, List
2 |
3 | from bunq.sdk.json import converter
4 | from bunq.sdk.model.core.id import Id
5 | from bunq.sdk.model.core.installation import Installation
6 | from bunq.sdk.model.core.public_key_server import PublicKeyServer
7 | from bunq.sdk.model.core.session_token import SessionToken
8 |
9 |
10 | class InstallationAdapter(converter.JsonAdapter):
11 | # Id constants
12 | _ATTRIBUTE_ID = '_id_'
13 | _INDEX_ID = 0
14 | _FIELD_ID = 'Id'
15 |
16 | # Token constants
17 | _ATTRIBUTE_TOKEN = '_token'
18 | _INDEX_TOKEN = 1
19 | _FIELD_TOKEN = 'Token'
20 |
21 | # Server Public Key constants
22 | _ATTRIBUTE_SERVER_PUBLIC_KEY = '_server_public_key'
23 | _INDEX_SERVER_PUBLIC_KEY = 2
24 | _FIELD_SERVER_PUBLIC_KEY = 'ServerPublicKey'
25 |
26 | @classmethod
27 | def deserialize(cls,
28 | target_class: Type[Installation],
29 | array: List) -> Installation:
30 | installation = target_class.__new__(target_class)
31 | server_public_key_wrapped = array[cls._INDEX_SERVER_PUBLIC_KEY]
32 | installation.__dict__ = {
33 | cls._ATTRIBUTE_ID: converter.deserialize(
34 | Id,
35 | array[cls._INDEX_ID][cls._FIELD_ID]
36 | ),
37 | cls._ATTRIBUTE_TOKEN: converter.deserialize(
38 | SessionToken,
39 | array[cls._INDEX_TOKEN][cls._FIELD_TOKEN]
40 | ),
41 | cls._ATTRIBUTE_SERVER_PUBLIC_KEY: converter.deserialize(
42 | PublicKeyServer,
43 | server_public_key_wrapped[cls._FIELD_SERVER_PUBLIC_KEY]
44 | ),
45 | }
46 |
47 | return installation
48 |
49 | @classmethod
50 | def serialize(cls, installation: Installation) -> List:
51 | return [
52 | {cls._FIELD_ID: converter.serialize(installation.id_)},
53 | {cls._FIELD_TOKEN: converter.serialize(installation.token)},
54 | {cls._FIELD_SERVER_PUBLIC_KEY: converter.serialize(installation.server_public_key)},
55 | ]
56 |
57 | @classmethod
58 | def can_serialize(cls) -> bool:
59 | return True
60 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/MonetaryAccountBank.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "DRAFT_PAYMENT",
5 | "event_type": "DRAFT_PAYMENT_ACCEPTED",
6 | "object": {
7 | "MonetaryAccountBank": {
8 | "id": 21,
9 | "created": "2017-07-08 00:42:33.951277",
10 | "updated": "2017-07-08 00:42:33.951277",
11 | "alias": [
12 | {
13 | "type": "IBAN",
14 | "value": "NL18BUNQ2025104049",
15 | "name": "bunq"
16 | }
17 | ],
18 | "avatar": {
19 | "uuid": "246519c2-1280-4fd8-a864-ed9d313ceeba",
20 | "image": [
21 | {
22 | "attachment_public_uuid": "52613480-7bd1-4da4-be8a-4a15f78c085d",
23 | "height": 126,
24 | "width": 200,
25 | "content_type|": "image\/jpeg"
26 | }
27 | ],
28 | "anchor_uuid": "45631547-6bdb-479e-859b-192273964ed4"
29 | },
30 | "balance": null,
31 | "country": "NL",
32 | "currency": "EUR",
33 | "daily_limit": {
34 | "currency": "EUR",
35 | "value": "10000.00"
36 | },
37 | "daily_spent": {
38 | "currency": "EUR",
39 | "value": "0.00"
40 | },
41 | "description": "sofort",
42 | "public_uuid": "45631547-6bdb-479e-859b-192273964ed4",
43 | "status": "ACTIVE",
44 | "sub_status": "NONE",
45 | "timezone": "europe\/amsterdam",
46 | "user_id": 214,
47 | "monetary_account_profile": null,
48 | "notification_filters": [],
49 | "setting": null,
50 | "overdraft_limit": {
51 | "currency": "EUR",
52 | "value": "0.00"
53 | }
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/bunq/sdk/http/anonymous_api_client.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | import requests
4 |
5 | from bunq.sdk.context.api_context import ApiContext
6 | from bunq.sdk.http.api_client import ApiClient
7 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
8 | from bunq.sdk.security import security
9 |
10 |
11 | class AnonymousApiClient(ApiClient):
12 |
13 | def __init__(self, api_context: ApiContext) -> None:
14 | super().__init__(api_context)
15 |
16 | def post(self,
17 | uri_relative: str,
18 | request_bytes: bytes,
19 | custom_headers: Dict[str, str]) -> BunqResponseRaw:
20 | return self._request(
21 | self.METHOD_POST,
22 | uri_relative,
23 | request_bytes,
24 | {},
25 | custom_headers
26 | )
27 |
28 | def _request(self,
29 | method: str,
30 | uri_relative: str,
31 | request_bytes: bytes,
32 | params: Dict[str, str],
33 | custom_headers: Dict[str, str]) -> BunqResponseRaw:
34 | from bunq.sdk.context.bunq_context import BunqContext
35 |
36 | uri_relative_with_params = self._append_params_to_uri(uri_relative, params)
37 | if uri_relative not in self._URIS_NOT_REQUIRING_ACTIVE_SESSION:
38 | if self._api_context.ensure_session_active():
39 | BunqContext.update_api_context(self._api_context)
40 |
41 | all_headers = self._get_all_headers(
42 | request_bytes,
43 | custom_headers
44 | )
45 |
46 | response = requests.request(
47 | method,
48 | uri_relative_with_params,
49 | data=request_bytes,
50 | headers=all_headers,
51 | proxies={self.FIELD_PROXY_HTTPS: self._api_context.proxy_url},
52 | )
53 |
54 | self._assert_response_success(response)
55 |
56 | if self._api_context.installation_context is not None:
57 | security.validate_response(
58 | self._api_context.installation_context.public_key_server,
59 | response.status_code,
60 | response.content,
61 | response.headers
62 | )
63 |
64 | return BunqResponseRaw(response.content, response.headers)
65 |
--------------------------------------------------------------------------------
/bunq/sdk/json/installation_context_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type, Dict
2 |
3 | from bunq import InstallationContext
4 | from bunq.sdk.json import converter
5 | from bunq.sdk.security import security
6 |
7 |
8 | class InstallationContextAdapter(converter.JsonAdapter):
9 | # Attribute/Field constants
10 | _ATTRIBUTE_TOKEN = '_token'
11 | _FIELD_TOKEN = 'token'
12 |
13 | _ATTRIBUTE_PRIVATE_KEY_CLIENT = '_private_key_client'
14 | _FIELD_PRIVATE_KEY_CLIENT = 'private_key_client'
15 |
16 | _ATTRIBUTE_PUBLIC_KEY_CLIENT = '_public_key_client'
17 | _FIELD_PUBLIC_KEY_CLIENT = 'public_key_client'
18 |
19 | _ATTRIBUTE_PUBLIC_KEY_SERVER = '_public_key_server'
20 | _FIELD_PUBLIC_KEY_SERVER = 'public_key_server'
21 |
22 | @classmethod
23 | def deserialize(cls,
24 | target_class: Type[InstallationContext],
25 | obj: Dict) -> InstallationContext:
26 | installation_context = target_class.__new__(target_class)
27 | private_key_client = security.rsa_key_from_string(
28 | obj[cls._FIELD_PRIVATE_KEY_CLIENT]
29 | )
30 | public_key_client = security.rsa_key_from_string(
31 | obj[cls._FIELD_PUBLIC_KEY_CLIENT]
32 | )
33 | public_key_server = security.rsa_key_from_string(
34 | obj[cls._FIELD_PUBLIC_KEY_SERVER]
35 | )
36 | installation_context.__dict__ = {
37 | cls._ATTRIBUTE_TOKEN: obj[cls._FIELD_TOKEN],
38 | cls._ATTRIBUTE_PRIVATE_KEY_CLIENT: private_key_client,
39 | cls._ATTRIBUTE_PUBLIC_KEY_CLIENT: public_key_client,
40 | cls._ATTRIBUTE_PUBLIC_KEY_SERVER: public_key_server,
41 | }
42 |
43 | return installation_context
44 |
45 | @classmethod
46 | def serialize(cls, installation_context: InstallationContext) -> Dict:
47 | return {
48 | cls._FIELD_TOKEN: installation_context.token,
49 | cls._FIELD_PUBLIC_KEY_CLIENT: security.public_key_to_string(
50 | installation_context.private_key_client.publickey()
51 | ),
52 | cls._FIELD_PRIVATE_KEY_CLIENT: security.private_key_to_string(
53 | installation_context.private_key_client
54 | ),
55 | cls._FIELD_PUBLIC_KEY_SERVER: security.public_key_to_string(
56 | installation_context.public_key_server
57 | ),
58 | }
59 |
--------------------------------------------------------------------------------
/bunq/sdk/json/share_detail_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Type, Optional
2 |
3 | from bunq.sdk.json import converter
4 | from bunq.sdk.model.generated.object_ import ShareDetailObject, ShareDetailPaymentObject, ShareDetailReadOnlyObject, \
5 | ShareDetailDraftPaymentObject
6 |
7 |
8 | class ShareDetailAdapter(converter.JsonAdapter):
9 | # Attribute/Field constants
10 | _ATTRIBUTE_PAYMENT = 'payment'
11 | _FIELD_PAYMENT = 'ShareDetailPayment'
12 |
13 | _ATTRIBUTE_READ_ONLY = 'read_only'
14 | _FIELD_READ_ONLY = 'ShareDetailReadOnly'
15 |
16 | _ATTRIBUTE_DRAFT_PAYMENT = 'draft_payment'
17 | _FIELD_DRAFT_PAYMENT = 'ShareDetailDraftPayment'
18 |
19 | @classmethod
20 | def deserialize(cls,
21 | target_class: Type[ShareDetailObject],
22 | obj: Dict) -> ShareDetailObject:
23 | """
24 | :type target_class: ShareDetail|type
25 | :type obj: dict
26 |
27 | :rtype: ShareDetail
28 | """
29 |
30 | share_detail = target_class.__new__(target_class)
31 | share_detail.__dict__ = {
32 | cls._ATTRIBUTE_PAYMENT: converter.deserialize(
33 | ShareDetailPaymentObject,
34 | cls._get_field_or_none(cls._FIELD_DRAFT_PAYMENT, obj)
35 | ),
36 | cls._ATTRIBUTE_READ_ONLY: converter.deserialize(
37 | ShareDetailReadOnlyObject,
38 | cls._get_field_or_none(cls._FIELD_READ_ONLY, obj)
39 | ),
40 | cls._ATTRIBUTE_DRAFT_PAYMENT: converter.deserialize(
41 | ShareDetailDraftPaymentObject,
42 | cls._get_field_or_none(cls._FIELD_DRAFT_PAYMENT, obj)
43 | ),
44 | }
45 |
46 | return share_detail
47 |
48 | @staticmethod
49 | def _get_field_or_none(field: str, obj: Dict) -> Optional[Dict]:
50 | return obj[field] if field in obj else None
51 |
52 | @classmethod
53 | def serialize(cls, share_detail: ShareDetailObject) -> Dict:
54 | return {
55 | cls._FIELD_PAYMENT: converter.serialize(
56 | share_detail._payment_field_for_request),
57 | cls._FIELD_READ_ONLY: converter.serialize(
58 | share_detail._read_only_field_for_request),
59 | cls._FIELD_DRAFT_PAYMENT: converter.serialize(
60 | share_detail._draft_payment
61 | ),
62 | }
63 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/installation.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.context.api_context import ApiContext
2 | from bunq.sdk.http.api_client import ApiClient
3 | from bunq.sdk.http.bunq_response import BunqResponse
4 | from bunq.sdk.json import converter
5 | from bunq.sdk.model.core.bunq_model import BunqModel
6 | from bunq.sdk.model.core.id import Id
7 | from bunq.sdk.model.core.public_key_server import PublicKeyServer
8 | from bunq.sdk.model.core.session_token import SessionToken
9 |
10 |
11 | class Installation(BunqModel):
12 | """
13 | :type _id_: Id
14 | :type _token: SessionToken
15 | :type _server_public_key: PublicKeyServer
16 | """
17 |
18 | # Endpoint name.
19 | _ENDPOINT_URL_POST = "installation"
20 |
21 | # Field constants.
22 | FIELD_CLIENT_PUBLIC_KEY = "client_public_key"
23 |
24 | def __init__(self) -> None:
25 | self._id_ = None
26 | self._token = None
27 | self._server_public_key = None
28 |
29 | @property
30 | def id_(self) -> Id:
31 | return self._id_
32 |
33 | @property
34 | def token(self) -> SessionToken:
35 | return self._token
36 |
37 | @property
38 | def server_public_key(self) -> PublicKeyServer:
39 | return self._server_public_key
40 |
41 | @classmethod
42 | def create(cls,
43 | api_context: ApiContext,
44 | public_key_string: str) -> BunqResponse: # TODO: Check
45 | """
46 | :type api_context: ApiContext
47 | :type public_key_string: str
48 |
49 | :rtype: BunqResponse[Installation]
50 | """
51 |
52 | api_client = ApiClient(api_context)
53 | body_bytes = cls.generate_request_body_bytes(public_key_string)
54 | response_raw = api_client.post(cls._ENDPOINT_URL_POST, body_bytes, {})
55 |
56 | return cls._from_json_array_nested(response_raw)
57 |
58 | @classmethod
59 | def generate_request_body_bytes(cls, public_key_string: str) -> bytes:
60 | return converter.class_to_json(
61 | {
62 | cls.FIELD_CLIENT_PUBLIC_KEY: public_key_string,
63 | }
64 | ).encode()
65 |
66 | def is_all_field_none(self) -> bool:
67 | if self.id_ is not None:
68 | return False
69 |
70 | if self.token is not None:
71 | return False
72 |
73 | if self.server_public_key is not None:
74 | return False
75 |
76 | return True
77 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/EXCEPTIONS.md:
--------------------------------------------------------------------------------
1 | ## Exceptions
2 | When you make a request via the SDK, there is a chance of request failing
3 | due to various reasons. When such a failure happens, an exception
4 | corresponding to the error occurred is raised.
5 |
6 | ----
7 | #### Possible Exceptions
8 | * `BadRequestException` If the request returns with status code `400`
9 | * `UnauthorizedException` If the request returns with status code `401`
10 | * `ForbiddenException` If the request returns with status code `403`
11 | * `NotFoundException` If the request returns with status code `404`
12 | * `MethodNotAllowedException` If the request returns with status code `405`
13 | * `TooManyRequestsException` If the request returns with status code `429`
14 | * `PleaseContactBunqException` If the request returns with status code `500`.
15 | If you get this exception, please contact us preferably via the support chat in the bunq app.
16 | * `UnknownApiErrorException` If none of the above mentioned exceptions are raised,
17 | this exception will be raised instead.
18 |
19 | For more information regarding these errors, please take a look on the documentation
20 | page here: https://doc.bunq.com/api/1/page/errors
21 |
22 | ---
23 | #### Base exception
24 | All the exceptions have the same base exception which looks like this:
25 | ```python
26 | class ApiException(Exception):
27 | def __init__(self,
28 | message: str,
29 | response_code: int) -> None:
30 | pass
31 |
32 | @property
33 | def message(self) -> str:
34 | return self._message
35 |
36 | @property
37 | def response_code(self) -> int:
38 | return self._response_code
39 | ```
40 | This means that each exception will have the response code and the error message
41 | related to the specific exception that has been raised.
42 |
43 | ---
44 | #### Exception handling
45 | Because we raise different exceptions for each error, you can catch an error
46 | if you expect it to be raised.
47 |
48 | ```python
49 | from bunq.sdk.exception.bad_request_exception import BadRequestException
50 | from bunq.sdk.context.api_context import ApiEnvironmentType, ApiContext
51 |
52 | API_KEY = "Some invalid API key"
53 | DESCRIPTION = "This will raise a BadRequestException"
54 |
55 | try:
56 | # Make a call that might raise an exception
57 | ApiContext.create(ApiEnvironmentType.SANDBOX, API_KEY, DESCRIPTION)
58 | except BadRequestException as error:
59 | # Do something if exception is raised
60 | print(error.response_code)
61 | print(error.message) # or just print(error)
62 | ```
63 |
--------------------------------------------------------------------------------
/bunq/sdk/http/pagination.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from bunq.sdk.exception.bunq_exception import BunqException
4 |
5 |
6 | class Pagination:
7 | """
8 | :type older_id: int|None
9 | :type newer_id: int|None
10 | :type future_id: int|None
11 | :type count: int|None
12 | """
13 |
14 | # Error constants
15 | _ERROR_NO_PREVIOUS_PAGE = 'Could not generate previous page URL params: there is no previous page.'
16 | _ERROR_NO_NEXT_PAGE = 'Could not generate next page URL params: there is no next page.'
17 |
18 | # URL Param constants
19 | PARAM_OLDER_ID = 'older_id'
20 | PARAM_NEWER_ID = 'newer_id'
21 | PARAM_COUNT = 'count'
22 |
23 | def __init__(self) -> None:
24 | self.older_id = None
25 | self.newer_id = None
26 | self.future_id = None
27 | self.count = None
28 |
29 | @property
30 | def url_params_previous_page(self) -> Dict[str, str]:
31 | self.assert_has_previous_page()
32 |
33 | params = {self.PARAM_OLDER_ID: str(self.older_id)}
34 | self._add_count_to_params_if_needed(params)
35 |
36 | return params
37 |
38 | def assert_has_previous_page(self) -> None:
39 | """
40 |
41 | :raise: BunqException
42 | """
43 |
44 | if not self.has_previous_page():
45 | raise BunqException(self._ERROR_NO_PREVIOUS_PAGE)
46 |
47 | def has_previous_page(self) -> bool:
48 | return self.older_id is not None
49 |
50 | @property
51 | def url_params_count_only(self) -> Dict[str, str]:
52 | params = {}
53 | self._add_count_to_params_if_needed(params)
54 |
55 | return params
56 |
57 | def _add_count_to_params_if_needed(self, params: Dict[str, str]) -> None:
58 | if self.count is not None:
59 | params[self.PARAM_COUNT] = str(self.count)
60 |
61 | def has_next_page_assured(self) -> bool:
62 | return self.newer_id is not None
63 |
64 | @property
65 | def url_params_next_page(self) -> Dict[str, str]:
66 | self.assert_has_next_page()
67 |
68 | params = {self.PARAM_NEWER_ID: str(self._next_id)}
69 | self._add_count_to_params_if_needed(params)
70 |
71 | return params
72 |
73 | def assert_has_next_page(self) -> None:
74 | """
75 |
76 | :raise: BunqException
77 | """
78 |
79 | if self._next_id is None:
80 | raise BunqException(self._ERROR_NO_NEXT_PAGE)
81 |
82 | @property
83 | def _next_id(self) -> int:
84 | if self.has_next_page_assured():
85 | return self.newer_id
86 |
87 | return self.future_id
88 |
--------------------------------------------------------------------------------
/bunq/sdk/json/anchor_object_adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from bunq import AnchorObjectInterface
4 | from bunq.sdk.exception.bunq_exception import BunqException
5 | from bunq.sdk.json import converter
6 | from bunq.sdk.model.core.bunq_model import BunqModel
7 | from bunq.sdk.model.generated import endpoint
8 | from bunq.sdk.model.generated import object_
9 | from bunq.sdk.util.type_alias import JsonValue
10 | from bunq.sdk.util.type_alias import T
11 |
12 |
13 | class AnchorObjectAdapter(converter.JsonAdapter):
14 | __ERROR_MODEL_NOT_FOUND = '{} is not in endpoint nor object.'
15 |
16 | __STRING_FORMAT_UNDERSCORE = '_'
17 |
18 | _override_field_map = {
19 | 'ScheduledPayment': 'SchedulePayment',
20 | 'ScheduledInstance': 'ScheduleInstance',
21 | 'ShareInviteBankInquiry': 'ShareInviteMonetaryAccountInquiry',
22 | 'ShareInviteBankResponse': 'ShareInviteMonetaryAccountResponse'
23 | }
24 |
25 | @classmethod
26 | def deserialize(cls,
27 | cls_target: Type[T],
28 | obj_raw: JsonValue) -> T:
29 | model_ = super()._deserialize_default(cls_target, obj_raw)
30 |
31 | if isinstance(model_, AnchorObjectInterface) and model_.is_all_field_none():
32 | for field in model_.__dict__:
33 | object_class = cls._get_object_class(field)
34 | contents = super()._deserialize_default(object_class, obj_raw)
35 |
36 | if contents.is_all_field_none():
37 | setattr(model_, field, None)
38 | else:
39 | setattr(model_, field, contents)
40 |
41 | return model_
42 |
43 | @classmethod
44 | def can_serialize(cls) -> bool:
45 | return False
46 |
47 | @classmethod
48 | def _get_object_class(cls, class_name: str) -> BunqModel:
49 | class_name = class_name.lstrip(cls.__STRING_FORMAT_UNDERSCORE)
50 |
51 | if class_name in cls._override_field_map:
52 | class_name = cls._override_field_map[class_name]
53 |
54 | try:
55 | return getattr(endpoint, class_name + "ApiObject")
56 | except AttributeError:
57 | pass
58 |
59 | try:
60 | return getattr(endpoint, class_name)
61 | except AttributeError:
62 | pass
63 |
64 | try:
65 | return getattr(object_, class_name + "Object")
66 | except AttributeError:
67 | pass
68 |
69 | try:
70 | return getattr(object_, class_name)
71 | except AttributeError:
72 | pass
73 |
74 | raise BunqException(cls.__ERROR_MODEL_NOT_FOUND.format(class_name))
75 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_card_debit.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | from bunq.sdk.context.bunq_context import BunqContext
5 | from bunq.sdk.model.generated.endpoint import CardApiObject
6 | from bunq.sdk.model.generated.endpoint import CardDebitApiObject
7 | from bunq.sdk.model.generated.endpoint import CardNameApiObject
8 | from bunq.sdk.model.generated.object_ import CardPinAssignmentObject
9 | from tests.bunq_test import BunqSdkTestCase
10 |
11 |
12 | class TestCardDebit(BunqSdkTestCase):
13 | """
14 | Tests:
15 | CardApiObject
16 | CardDebitApiObject
17 | CardNameApiObject
18 | """
19 |
20 | _CARD_PIN_CODE = '4045'
21 | _SECOND_LINE_LENGTH_MAXIMUM = 20
22 | _STRING_EMPTY = ''
23 | _PIN_CODE_ASSIGNMENT_TYPE_PRIMARY = 'PRIMARY'
24 | _CARD_TYPE_MASTERCARD = 'MASTERCARD'
25 | _PRODUCT_TYPE_MASTERCARD_DEBIT = 'MASTERCARD_DEBIT'
26 | _CARD_ROUTING_TYPE = 'MANUAL'
27 |
28 | def test_order_debit_card(self):
29 | """
30 | Tests ordering a new card and checks if the fields we have entered
31 | are indeed correct by retrieving the card from the card endpoint and
32 | checks this date against the data we have submitted
33 | """
34 |
35 | second_line = self.second_line_random
36 | pin_code_assignment = CardPinAssignmentObject(
37 | self._PIN_CODE_ASSIGNMENT_TYPE_PRIMARY,
38 | self._CARD_ROUTING_TYPE,
39 | self._CARD_PIN_CODE,
40 | BunqContext.user_context().primary_monetary_account.id_
41 | )
42 |
43 | card_debit = CardDebitApiObject.create(
44 | second_line=second_line,
45 | name_on_card=self.card_name_allowed,
46 | type_=self._CARD_TYPE_MASTERCARD,
47 | product_type=self._PRODUCT_TYPE_MASTERCARD_DEBIT,
48 | alias=self.alias_first,
49 | pin_code_assignment=[pin_code_assignment]
50 | ).value
51 |
52 | card = CardApiObject.get(card_debit.id_).value
53 |
54 | self.assertEqual(self.card_name_allowed, card.name_on_card)
55 | self.assertEqual(second_line, card.second_line)
56 | self.assertEqual(card_debit.created, card.created)
57 |
58 | @property
59 | def card_name_allowed(self) -> str:
60 | return CardNameApiObject.list().value[self._FIRST_INDEX].possible_card_name_array[self._FIRST_INDEX]
61 |
62 | @property
63 | def second_line_random(self) -> str:
64 | second_line_characters = []
65 |
66 | for _ in range(self._SECOND_LINE_LENGTH_MAXIMUM):
67 | next_char = random.choice(string.ascii_uppercase)
68 | second_line_characters.append(next_char)
69 |
70 | return self._STRING_EMPTY.join(second_line_characters)
71 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/oauth_authorization_uri.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from bunq import ApiEnvironmentType
4 | from bunq.sdk.context.bunq_context import BunqContext
5 | from bunq.sdk.exception.bunq_exception import BunqException
6 | from bunq.sdk.http.http_util import HttpUtil
7 | from bunq.sdk.model.core.bunq_model import BunqModel
8 | from bunq.sdk.model.core.oauth_response_type import OauthResponseType
9 | from bunq.sdk.model.generated.endpoint import OauthClient
10 |
11 |
12 | class OauthAuthorizationUri(BunqModel):
13 | # Auth constants.
14 | AUTH_URI_FORMAT_SANDBOX = "https://oauth.sandbox.bunq.com/auth?{}"
15 | AUTH_URI_FORMAT_PRODUCTION = "https://oauth.bunq.com/auth?{}"
16 |
17 | # Field constants
18 | FIELD_RESPONSE_TYPE = "response_type"
19 | FIELD_REDIRECT_URI = "redirect_uri"
20 | FIELD_STATE = "state"
21 | FIELD_CLIENT_ID = "client_id"
22 |
23 | # Error constants.
24 | ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type."
25 |
26 | def __init__(self, authorization_uri: str) -> None:
27 | self._authorization_uri = authorization_uri
28 |
29 | @property
30 | def authorization_uri(self) -> str:
31 | return self._authorization_uri
32 |
33 | @classmethod
34 | def create(cls,
35 | response_type: OauthResponseType,
36 | redirect_uri: str,
37 | client: OauthClient,
38 | state: str = None) -> OauthAuthorizationUri:
39 | all_request_parameter = {
40 | cls.FIELD_REDIRECT_URI: redirect_uri,
41 | cls.FIELD_RESPONSE_TYPE: response_type.name.lower()
42 | }
43 |
44 | if client.client_id is not None:
45 | all_request_parameter[cls.FIELD_CLIENT_ID] = client.client_id
46 |
47 | if state is not None:
48 | all_request_parameter[cls.FIELD_STATE] = state
49 |
50 | return OauthAuthorizationUri(
51 | cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_request_parameter))
52 | )
53 |
54 | def get_authorization_uri(self) -> str:
55 | return self._authorization_uri
56 |
57 | def is_all_field_none(self) -> bool:
58 | if self._authorization_uri is None:
59 | return True
60 | else:
61 | return False
62 |
63 | @classmethod
64 | def determine_auth_uri_format(cls) -> str:
65 | environment_type = BunqContext.api_context().environment_type
66 |
67 | if ApiEnvironmentType.PRODUCTION == environment_type:
68 | return cls.AUTH_URI_FORMAT_PRODUCTION
69 |
70 | if ApiEnvironmentType.SANDBOX == environment_type:
71 | return cls.AUTH_URI_FORMAT_SANDBOX
72 |
73 | raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED)
74 |
--------------------------------------------------------------------------------
/tests/model/generated/endpoint/test_payment.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from bunq.sdk.model.generated.endpoint import PaymentApiObject, PaymentBatchApiObject, BunqResponseInt, BunqResponsePaymentBatch
4 | from bunq.sdk.model.generated.object_ import AmountObject, PointerObject
5 | from tests.bunq_test import BunqSdkTestCase
6 |
7 |
8 | class TestPayment(BunqSdkTestCase):
9 | """
10 | Tests:
11 | PaymentApiObject
12 | PaymentChat
13 | ChatMessageText
14 | """
15 |
16 | _PAYMENT_AMOUNT_EUR = '0.01'
17 | _PAYMENT_CURRENCY = 'EUR'
18 | _PAYMENT_DESCRIPTION = 'Python unit test'
19 | _PAYMENT_CHAT_TEXT_MESSAGE = 'send from python test'
20 |
21 | _MAXIMUM_PAYMENT_IN_BATCH = 10
22 |
23 | def test_payment_to_other_user(self):
24 | """
25 | Tests making a payment to another sandbox user
26 |
27 | This test has no assertion as of its testing to see if the code runs
28 | without errors
29 | """
30 |
31 | PaymentApiObject.create(
32 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY),
33 | self._get_pointer_bravo(),
34 | self._PAYMENT_DESCRIPTION
35 | )
36 |
37 | def test_payment_to_other_account(self):
38 | """
39 | Tests making a payment to another monetary account of the same user
40 |
41 | This test has no assertion as of its testing to see if the code runs
42 | without errors
43 | """
44 |
45 | PaymentApiObject.create(
46 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY),
47 | self._get_alias_second_account(),
48 | self._PAYMENT_DESCRIPTION
49 | )
50 |
51 | def test_payment_batch(self):
52 | response_create = PaymentBatchApiObject.create(self.__create_payment_list())
53 |
54 | self.assertIsInstance(response_create, BunqResponseInt)
55 | self.assertIsNotNone(response_create)
56 |
57 | response_get = PaymentBatchApiObject.get(response_create.value)
58 |
59 | self.assertIsInstance(response_get, BunqResponsePaymentBatch)
60 | self.assertIsNotNone(response_get)
61 | self.assertFalse(response_get.value.is_all_field_none())
62 |
63 | def __create_payment_list(self) -> List[PaymentApiObject]:
64 | all_payment = []
65 |
66 | while len(all_payment) < self._MAXIMUM_PAYMENT_IN_BATCH:
67 | all_payment.append(
68 | PaymentApiObject(
69 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY),
70 | PointerObject(self._POINTER_EMAIL, self._EMAIL_BRAVO),
71 | self._PAYMENT_DESCRIPTION
72 | )
73 | )
74 | self.assertIsInstance(all_payment, List)
75 | self.assertIsInstance(all_payment[0], PaymentApiObject)
76 |
77 | return all_payment
78 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/device_server_internal.py:
--------------------------------------------------------------------------------
1 | from typing import List, Dict
2 |
3 | from bunq.sdk.context.api_context import ApiContext
4 | from bunq.sdk.exception.bunq_exception import BunqException
5 | from bunq.sdk.http.api_client import ApiClient
6 | from bunq.sdk.json import converter
7 | from bunq.sdk.model.generated.endpoint import BunqResponseInt
8 | from bunq.sdk.model.generated.endpoint import DeviceServerApiObject
9 |
10 |
11 | class DeviceServerInternal(DeviceServerApiObject):
12 | _ERROR_API_CONTEXT_IS_NULL = 'ApiContext should not be None, use the generated class instead.'
13 |
14 | @classmethod
15 | def create(cls,
16 | description: str,
17 | secret: str,
18 | permitted_ips: List[str] = None,
19 | custom_headers: Dict[str, str] = None,
20 | api_context: ApiContext = None) -> BunqResponseInt:
21 | """
22 | Create a new DeviceServer providing the installation token in the header
23 | and signing the request with the private part of the key you used to
24 | create the installation. The API Key that you are using will be bound to
25 | the IP address of the DeviceServer which you have
26 | created.
Using a Wildcard API Key gives you the freedom to make
27 | API calls even if the IP address has changed after the POST
28 | device-server.
Find out more at this link https://bunq.com/en/apikey-dynamic-ip.
31 |
32 | :param description: The description of the DeviceServer. This is only
33 | for your own reference when reading the DeviceServer again.
34 | :type description: str
35 | :param secret: The API key. You can request an API key in the bunq app.
36 | :type secret: str
37 | :param permitted_ips: An array of IPs (v4 or v6) this DeviceServer will
38 | be able to do calls from. These will be linked to the API key.
39 | :type permitted_ips: list[str]
40 | :type custom_headers: dict[str, str]|None
41 | :type api_context: ApiContext
42 |
43 | :rtype: BunqResponseInt
44 | """
45 |
46 | if api_context is None:
47 | raise BunqException(cls._ERROR_API_CONTEXT_IS_NULL)
48 |
49 | if custom_headers is None:
50 | custom_headers = {}
51 |
52 | request_map = {
53 | cls.FIELD_DESCRIPTION: description,
54 | cls.FIELD_SECRET: secret,
55 | cls.FIELD_PERMITTED_IPS: permitted_ips
56 | }
57 |
58 | api_client = ApiClient(api_context)
59 | request_bytes = converter.class_to_json(request_map).encode()
60 | endpoint_url = cls._ENDPOINT_URL_CREATE
61 | response_raw = api_client.post(endpoint_url, request_bytes,
62 | custom_headers)
63 |
64 | return BunqResponseInt.cast_from_bunq_response(
65 | cls._process_for_id(response_raw)
66 | )
67 |
--------------------------------------------------------------------------------
/bunq/sdk/json/pagination_adapter.py:
--------------------------------------------------------------------------------
1 | import urllib.parse as urlparse
2 | from typing import Type, Dict
3 |
4 | from bunq import Pagination
5 | from bunq.sdk.json import converter
6 |
7 |
8 | class PaginationAdapter(converter.JsonAdapter):
9 | # Raw pagination response field constants.
10 | _FIELD_FUTURE_URL = 'future_url'
11 | _FIELD_NEWER_URL = 'newer_url'
12 | _FIELD_OLDER_URL = 'older_url'
13 |
14 | # Processed pagination field constants.
15 | _FIELD_OLDER_ID = 'older_id'
16 | _FIELD_NEWER_ID = 'newer_id'
17 | _FIELD_FUTURE_ID = 'future_id'
18 | _FIELD_COUNT = 'count'
19 |
20 | # Very first index in an array.
21 | _INDEX_FIRST = 0
22 |
23 | @classmethod
24 | def deserialize(cls,
25 | target_class: Type[Pagination],
26 | pagination_response: Dict) -> Pagination:
27 | pagination = Pagination()
28 | pagination.__dict__.update(
29 | cls.parse_pagination_dict(pagination_response)
30 | )
31 |
32 | return pagination
33 |
34 | @classmethod
35 | def parse_pagination_dict(cls, response_obj: Dict) -> Dict:
36 | pagination_dict = {}
37 |
38 | cls.update_dict_id_field_from_response_field(
39 | pagination_dict,
40 | cls._FIELD_OLDER_ID,
41 | response_obj,
42 | cls._FIELD_OLDER_URL,
43 | Pagination.PARAM_OLDER_ID
44 | )
45 | cls.update_dict_id_field_from_response_field(
46 | pagination_dict,
47 | cls._FIELD_NEWER_ID,
48 | response_obj,
49 | cls._FIELD_NEWER_URL,
50 | Pagination.PARAM_NEWER_ID
51 | )
52 | cls.update_dict_id_field_from_response_field(
53 | pagination_dict,
54 | cls._FIELD_FUTURE_ID,
55 | response_obj,
56 | cls._FIELD_FUTURE_URL,
57 | Pagination.PARAM_NEWER_ID
58 | )
59 |
60 | return pagination_dict
61 |
62 | @classmethod
63 | def update_dict_id_field_from_response_field(cls, dict_: Dict,
64 | dict_id_field: str,
65 | response_obj: Dict,
66 | response_field: str,
67 | response_param: str) -> None:
68 | url = response_obj[response_field]
69 |
70 | if url is not None:
71 | url_parsed = urlparse.urlparse(url)
72 | parameters = urlparse.parse_qs(url_parsed.query)
73 | dict_[dict_id_field] = int(
74 | parameters[response_param][cls._INDEX_FIRST]
75 | )
76 |
77 | if cls._FIELD_COUNT in parameters and cls._FIELD_COUNT not in dict_:
78 | dict_[cls._FIELD_COUNT] = int(
79 | parameters[Pagination.PARAM_COUNT][cls._INDEX_FIRST]
80 | )
81 |
82 | @classmethod
83 | def serialize(cls, pagination: Pagination) -> None:
84 | raise NotImplementedError()
85 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/ShareInviteBankResponse.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "SHARE",
5 | "event_type": "SHARE_INVITE_BANK_INQUIRY_ACCEPTED",
6 | "object": {
7 | "ShareInviteBankResponse": {
8 | "id": 2,
9 | "created": "2017-07-20 02:32:32.114297",
10 | "updated": "2017-07-20 02:32:32.114297",
11 | "monetary_account_id": null,
12 | "draft_share_invite_bank_id": null,
13 | "counter_alias": {
14 | "iban": "NL59BUNQ2025104669",
15 | "is_light": false,
16 | "display_name": "Alpha Corp.",
17 | "avatar": {
18 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a",
19 | "image": [
20 | {
21 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439",
22 | "height": 1024,
23 | "width": 1024,
24 | "content_type": "image\/png"
25 | }
26 | ],
27 | "anchor_uuid": null
28 | },
29 | "label_user": {
30 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3",
31 | "display_name": "Alpha Corp.",
32 | "country": "NL",
33 | "avatar": {
34 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9",
35 | "image": [
36 | {
37 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a",
38 | "height": 640,
39 | "width": 640,
40 | "content_type": "image\/png"
41 | }
42 | ],
43 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3"
44 | },
45 | "public_nick_name": "Alpha Corp."
46 | },
47 | "country": "NL"
48 | },
49 | "user_alias_cancelled": null,
50 | "description": "bunq account",
51 | "share_type": "STANDARD",
52 | "status": "PENDING",
53 | "share_detail": {
54 | "ShareDetailPayment": {
55 | "view_balance": true,
56 | "view_old_events": true,
57 | "view_new_events": true,
58 | "budget": null,
59 | "make_payments": true
60 | }
61 | },
62 | "start_date": "2017-07-20 02:34:05.864029",
63 | "end_date": "2017-11-11 02:34:05.864029"
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """
2 | A setuptools based setup module.
3 |
4 | See:
5 | https://packaging.python.org/en/latest/distributing.html
6 | https://github.com/pypa/sampleproject
7 | """
8 |
9 | # To use a consistent encoding
10 | from codecs import open
11 | from os import path
12 |
13 | # Always prefer setuptools over distutils
14 | from setuptools import setup, find_packages
15 |
16 | here = path.abspath(path.dirname(__file__))
17 |
18 | # Get the long description from the README file
19 | with open(path.join(here, 'README.md'), encoding='utf-8') as f:
20 | long_description = f.read()
21 |
22 | # Get the version from the VERSION file
23 | with open(path.join(here, 'VERSION'), encoding='utf-8') as f:
24 | version = f.read()
25 |
26 | setup(
27 | name='bunq_sdk',
28 |
29 | # Versions should comply with PEP440. For a discussion on single-sourcing
30 | # the version across setup.py and the project code, see
31 | # https://packaging.python.org/en/latest/single_source_version.html
32 | version=version,
33 |
34 | description='bunq Python SDK',
35 | long_description=long_description,
36 | long_description_content_type="text/markdown",
37 |
38 | # The project's main homepage.
39 | url='https://github.com/bunq/sdk_python',
40 |
41 | # Author details
42 | author='bunq',
43 | author_email='support@bunq.com',
44 |
45 | # The project's license
46 | license='MIT',
47 |
48 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
49 | classifiers=[
50 | # How mature is this project? Common values are
51 | # 3 - Alpha
52 | # 4 - Beta
53 | # 5 - Production/Stable
54 | 'Development Status :: 4 - Beta',
55 |
56 | # Indicate who your project is intended for
57 | 'Intended Audience :: Developers',
58 | 'Topic :: Software Development :: Build Tools',
59 |
60 | # Pick your license as you wish (should match "license" above)
61 | 'License :: OSI Approved :: MIT License',
62 |
63 | # Specify the Python versions you support here. In particular, ensure
64 | # that you indicate whether you support Python 2, Python 3 or both.
65 | 'Programming Language :: Python :: 3.7',
66 | ],
67 |
68 | # Require Python version equal or higher than the requested version.
69 | python_requires='>=3.7.0',
70 |
71 | # Keywords related to the project
72 | keywords='open-banking sepa bunq finance api payment',
73 |
74 | # Packages of the project. "find_packages()" lists all the project packages.
75 | packages=find_packages(exclude=[
76 | 'contrib',
77 | 'docs',
78 | 'tests',
79 | 'examples',
80 | 'assets',
81 | '.idea',
82 | 'run.py'
83 | ]),
84 |
85 | # Run-time dependencies of the project. These will be installed by pip.
86 | install_requires=[
87 | 'aenum>=2.2.4,<3.0.0',
88 | 'chardet>=3.0.4,<4.0.0',
89 | 'pycryptodomex>=3.9.8,<4.0.0',
90 | 'requests>=2.24.0,<3.0.0',
91 | 'simplejson>=3.17.2,<4.0.0',
92 | 'urllib3>=1.25.10,<2.0.0'
93 | ],
94 | )
--------------------------------------------------------------------------------
/tests/config.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | from typing import List, Any
5 |
6 | from bunq.sdk.model.generated.object_ import Pointer
7 |
8 |
9 | class Config:
10 | # Delimiter between the IP addresses in the PERMITTED_IPS field.
11 | _DELIMITER_IPS = ","
12 |
13 | # Field constants
14 | _FIELD_PERMITTED_IPS = "PERMITTED_IPS"
15 | _FIELD_COUNTER_PARTY_OTHER = "CounterPartyOther"
16 | _FIELD_COUNTER_PARTY_SELF = "CounterPartySelf"
17 | _FIELD_TYPE = "Type"
18 | _FIELD_ALIAS = "Alias"
19 | _FIELD_TAB_USAGE = "TabUsageSingleTest"
20 | _FIELD_MONETARY_ACCOUNT_ID_1 = "MONETARY_ACCOUNT_ID"
21 | _FIELD_MONETARY_ACCOUNT_ID_2 = "MONETARY_ACCOUNT_ID2"
22 | _FIELD_USER_ID = "USER_ID"
23 | _FIELD_API_KEY = "API_KEY"
24 | _FIELD_ATTACHMENT_PUBLIC = "AttachmentPublicTest"
25 | _FIELD_ATTACHMENT_PATH_IN = "PATH_IN"
26 | _FIELD_ATTACHMENT_DESCRIPTION = "DESCRIPTION"
27 | _FIELD_ATTACHMENT_CONTENT_TYPE = "CONTENT_TYPE"
28 |
29 | @classmethod
30 | def get_attachment_content_type(cls) -> str:
31 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_CONTENT_TYPE]
32 |
33 | @classmethod
34 | def get_attachment_description(cls) -> str:
35 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_DESCRIPTION]
36 |
37 | @classmethod
38 | def get_attachment_path_in(cls) -> str:
39 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_PATH_IN]
40 |
41 | @classmethod
42 | def get_api_key(cls) -> str:
43 | return cls._get_config_file()[cls._FIELD_API_KEY]
44 |
45 | @classmethod
46 | def get_user_id(cls) -> int:
47 | return int(cls._get_config_file()[cls._FIELD_USER_ID])
48 |
49 | @classmethod
50 | def get_monetary_account_id_2(cls) -> int:
51 | return int(cls._get_config_file()[cls._FIELD_MONETARY_ACCOUNT_ID_2])
52 |
53 | @classmethod
54 | def get_monetary_account_id_1(cls) -> int:
55 | return int(cls._get_config_file()[cls._FIELD_MONETARY_ACCOUNT_ID_1])
56 |
57 | @classmethod
58 | def get_pointer_counter_party_self(cls) -> Pointer:
59 | type_ = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_SELF][cls._FIELD_TYPE]
60 | alias = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_SELF][cls._FIELD_ALIAS]
61 |
62 | return Pointer(type_, alias)
63 |
64 | @classmethod
65 | def get_pointer_counter_party_other(cls) -> Pointer:
66 | type_ = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_OTHER][cls._FIELD_TYPE]
67 | alias = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_OTHER][cls._FIELD_ALIAS]
68 |
69 | return Pointer(type_, alias)
70 |
71 | @classmethod
72 | def get_permitted_ips(cls) -> List[str]:
73 | permitted_ips_str = cls._get_config_file()[cls._FIELD_PERMITTED_IPS]
74 |
75 | if not permitted_ips_str:
76 | return []
77 |
78 | return permitted_ips_str.split(cls._DELIMITER_IPS)
79 |
80 | @classmethod
81 | def _get_config_file(cls) -> Any:
82 | file_path = os.path.dirname(os.path.realpath(__file__))
83 | with open(file_path + "/assets/config.json", "r") as f:
84 | return json.load(f)
85 |
--------------------------------------------------------------------------------
/tests/http/test_pagination_scenario.py:
--------------------------------------------------------------------------------
1 | import time
2 | from typing import List, Dict
3 |
4 | from bunq import Pagination
5 | from bunq.sdk.context.bunq_context import BunqContext
6 | from bunq.sdk.http.bunq_response import BunqResponse
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import PaymentApiObject
9 | from bunq.sdk.model.generated.object_ import AmountObject
10 | from tests.bunq_test import BunqSdkTestCase
11 |
12 |
13 | class TestPaginationScenario(BunqSdkTestCase):
14 | """
15 | Tests:
16 | Pagination
17 | """
18 |
19 | __TIME_OUT_PREVENT_RATE_LIMIT = 2
20 |
21 | @classmethod
22 | def setUpClass(cls):
23 | cls._PAYMENT_LISTING_PAGE_SIZE = 2
24 | cls._PAYMENT_REQUIRED_COUNT_MINIMUM = cls._PAYMENT_LISTING_PAGE_SIZE * 2
25 | cls._NUMBER_ZERO = 0
26 | cls._PAYMENT_AMOUNT_EUR = '0.01'
27 | cls._PAYMENT_CURRENCY = 'EUR'
28 | cls._PAYMENT_DESCRIPTION = 'Python test Payment'
29 |
30 | BunqContext.load_api_context(cls._get_api_context())
31 |
32 | def test_api_scenario_payment_listing_with_pagination(self):
33 | self._ensure_enough_payments()
34 | payments_expected = self._payments_required()
35 | pagination = Pagination()
36 | pagination.count = self._PAYMENT_LISTING_PAGE_SIZE
37 |
38 | time.sleep(self.__TIME_OUT_PREVENT_RATE_LIMIT)
39 | response_latest = self._list_payments(pagination.url_params_count_only)
40 | pagination_latest = response_latest.pagination
41 |
42 | time.sleep(self.__TIME_OUT_PREVENT_RATE_LIMIT)
43 | response_previous = self._list_payments(pagination_latest.url_params_previous_page)
44 | pagination_previous = response_previous.pagination
45 |
46 | response_previous_next = self._list_payments(pagination_previous.url_params_next_page)
47 | payments_previous = response_previous.value
48 | payments_previous_next = response_previous_next.value
49 | payments_actual = payments_previous_next + payments_previous
50 | payments_expected_serialized = converter.serialize(payments_expected)
51 | payments_actual_serialized = converter.serialize(payments_actual)
52 |
53 | self.assertEqual(payments_expected_serialized, payments_actual_serialized)
54 |
55 | def _ensure_enough_payments(self) -> None:
56 | for _ in range(self._payment_missing_count):
57 | self._create_payment()
58 |
59 | @property
60 | def _payment_missing_count(self) -> int:
61 | return self._PAYMENT_REQUIRED_COUNT_MINIMUM - len(self._payments_required())
62 |
63 | def _payments_required(self) -> List[PaymentApiObject]:
64 | pagination = Pagination()
65 | pagination.count = self._PAYMENT_REQUIRED_COUNT_MINIMUM
66 |
67 | return self._list_payments(pagination.url_params_count_only).value
68 |
69 | @staticmethod
70 | def _list_payments(params: Dict[str, str]) -> BunqResponse[List[PaymentApiObject]]:
71 | return PaymentApiObject.list(params=params)
72 |
73 | def _create_payment(self) -> None:
74 | PaymentApiObject.create(
75 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY),
76 | self._get_pointer_bravo(),
77 | self._PAYMENT_DESCRIPTION
78 | )
79 |
--------------------------------------------------------------------------------
/bunq/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Type
2 |
3 | from bunq.sdk.context.api_environment_type import ApiEnvironmentType
4 | from bunq.sdk.context.installation_context import InstallationContext
5 | from bunq.sdk.http.pagination import Pagination
6 | from bunq.sdk.json import converter
7 | from bunq.sdk.model.core.anchor_object_interface import AnchorObjectInterface
8 | from bunq.sdk.model.generated.object_ import GeolocationObject, ShareDetailObject, MonetaryAccountReference
9 | from bunq.sdk.util.type_alias import T
10 |
11 |
12 | def initialize_converter() -> None:
13 | import datetime
14 | import inspect
15 |
16 | from bunq.sdk.http import api_client
17 | from bunq.sdk.context import api_context
18 | from bunq.sdk.json import converter
19 | from bunq.sdk.model.generated import object_
20 | from bunq.sdk.model.generated import endpoint
21 | from bunq.sdk.model.core.installation import Installation
22 | from bunq.sdk.model.core.session_server import SessionServer
23 | from bunq.sdk.json.installation_adapter import InstallationAdapter
24 | from bunq.sdk.json.session_server_adapter import SessionServerAdapter
25 | from bunq.sdk.json.installation_context_adapter import InstallationContextAdapter
26 | from bunq.sdk.json.api_environment_type_adapter import ApiEnvironmentTypeAdapter
27 | from bunq.sdk.json.float_adapter import FloatAdapter
28 | from bunq.sdk.json.geolocation_adapter import GeolocationAdapter
29 | from bunq.sdk.json.monetary_account_reference_adapter import MonetaryAccountReferenceAdapter
30 | from bunq.sdk.json.share_detail_adapter import ShareDetailAdapter
31 | from bunq.sdk.json.date_time_adapter import DateTimeAdapter
32 | from bunq.sdk.json.pagination_adapter import PaginationAdapter
33 | from bunq.sdk.json.anchor_object_adapter import AnchorObjectAdapter
34 |
35 | converter.register_adapter(Installation, InstallationAdapter)
36 | converter.register_adapter(SessionServer, SessionServerAdapter)
37 | converter.register_adapter(InstallationContext, InstallationContextAdapter)
38 | converter.register_adapter(ApiEnvironmentType, ApiEnvironmentTypeAdapter)
39 | converter.register_adapter(float, FloatAdapter)
40 | converter.register_adapter(GeolocationObject, GeolocationAdapter)
41 | converter.register_adapter(MonetaryAccountReference, MonetaryAccountReferenceAdapter)
42 | converter.register_adapter(ShareDetailObject, ShareDetailAdapter)
43 | converter.register_adapter(datetime.datetime, DateTimeAdapter)
44 | converter.register_adapter(Pagination, PaginationAdapter)
45 |
46 | def register_anchor_adapter(class_to_register: Type[T]) -> None:
47 | if issubclass(class_to_register, AnchorObjectInterface):
48 | converter.register_adapter(class_to_register, AnchorObjectAdapter)
49 |
50 | def get_class(class_string_to_get: str) -> Type[T]:
51 | if hasattr(object_, class_string_to_get):
52 | return getattr(object_, class_string_to_get)
53 |
54 | if hasattr(endpoint, class_string_to_get):
55 | return getattr(endpoint, class_string_to_get)
56 |
57 | for class_string in list(dir(object_) + dir(endpoint)):
58 | class_ = get_class(class_string)
59 |
60 | if not inspect.isclass(class_):
61 | continue
62 |
63 | register_anchor_adapter(class_)
64 |
65 |
66 | converter.set_initializer_function(initialize_converter)
67 |
--------------------------------------------------------------------------------
/tests/model/core/test_notification_filter.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.context.bunq_context import BunqContext
2 | from bunq.sdk.model.core.notification_filter_push_user_internal import NotificationFilterPushUserInternal
3 | from bunq.sdk.model.core.notification_filter_url_monetary_account_internal import \
4 | NotificationFilterUrlMonetaryAccountInternal
5 | from bunq.sdk.model.core.notification_filter_url_user_internal import NotificationFilterUrlUserInternal
6 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl, NotificationFilterPush
7 | from tests.bunq_test import BunqSdkTestCase
8 |
9 |
10 | class TestNotificationFilter(BunqSdkTestCase):
11 | _FILTER_CATEGORY_MUTATION = 'MUTATION'
12 | _FILTER_CALLBACK_URL = 'https://test.com/callback'
13 |
14 | def test_notification_filter_url_monetary_account(self):
15 | notification_filter = self.get_notification_filter_url()
16 | all_notification_filter = [notification_filter]
17 |
18 | all_created_notification_filter = NotificationFilterUrlMonetaryAccountInternal.create_with_list_response(
19 | self.get_primary_monetary_account().id_,
20 | all_notification_filter
21 | ).value
22 |
23 | self.assertEqual(1, len(all_created_notification_filter))
24 |
25 | def test_notification_filter_url_user(self):
26 | notification_filter = self.get_notification_filter_url()
27 | all_notification_filter = [notification_filter]
28 |
29 | all_created_notification_filter = NotificationFilterUrlUserInternal.create_with_list_response(
30 | all_notification_filter
31 | ).value
32 |
33 | self.assertEqual(1, len(all_created_notification_filter))
34 |
35 | def test_notification_filter_push_user(self):
36 | notification_filter = self.get_notification_filter_push()
37 | all_notification_filter = [notification_filter]
38 |
39 | all_created_notification_filter = NotificationFilterPushUserInternal.create_with_list_response(
40 | all_notification_filter
41 | ).value
42 |
43 | self.assertEqual(1, len(all_created_notification_filter))
44 |
45 | def test_notification_filter_clear(self):
46 | all_created_notification_filter_push_user = NotificationFilterPushUserInternal.create_with_list_response().value
47 | all_created_notification_filter_url_user = NotificationFilterUrlUserInternal.create_with_list_response().value
48 | all_created_notification_filter_url_monetary_account = \
49 | NotificationFilterUrlMonetaryAccountInternal.create_with_list_response().value
50 |
51 | self.assertFalse(all_created_notification_filter_push_user)
52 | self.assertFalse(all_created_notification_filter_url_user)
53 | self.assertFalse(all_created_notification_filter_url_monetary_account)
54 |
55 | self.assertEqual(0, len(NotificationFilterPushUserInternal.list().value))
56 | self.assertEqual(0, len(NotificationFilterUrlUserInternal.list().value))
57 | self.assertEqual(0, len(NotificationFilterUrlMonetaryAccountInternal.list().value))
58 |
59 | def get_notification_filter_url(self):
60 | return NotificationFilterUrl(self._FILTER_CATEGORY_MUTATION, self._FILTER_CALLBACK_URL)
61 |
62 | def get_notification_filter_push(self):
63 | return NotificationFilterPush(self._FILTER_CATEGORY_MUTATION)
64 |
65 | @staticmethod
66 | def get_primary_monetary_account():
67 | return BunqContext.user_context().primary_monetary_account
68 |
--------------------------------------------------------------------------------
/tests/assets/ResponseJsons/MonetaryAccountJoint.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": 8948,
3 | "created": "2018-04-03 14:12:31.512599",
4 | "updated": "2018-04-03 14:12:31.512599",
5 | "alias": [
6 | {
7 | "type": "IBAN",
8 | "value": "NL19BUNQ2025192010",
9 | "name": "New User"
10 | }
11 | ],
12 | "avatar": {
13 | "uuid": "063e74a4-d40b-4961-9395-0eba2d68ee01",
14 | "image": [
15 | {
16 | "attachment_public_uuid": "8a9ef272-db4a-4c1c-aac0-cc6ee2a4ebcc",
17 | "height": 1023,
18 | "width": 1024,
19 | "content_type": "image\/png"
20 | }
21 | ],
22 | "anchor_uuid": "c747c366-27b7-48bd-97e6-7311dd8bcd6e"
23 | },
24 | "balance": {
25 | "currency": "EUR",
26 | "value": "0.00"
27 | },
28 | "country": "NL",
29 | "currency": "EUR",
30 | "daily_limit": {
31 | "currency": "EUR",
32 | "value": "1000.00"
33 | },
34 | "daily_spent": {
35 | "currency": "EUR",
36 | "value": "0.00"
37 | },
38 | "description": "H",
39 | "public_uuid": "c747c366-27b7-48bd-97e6-7311dd8bcd6e",
40 | "status": "PENDING_ACCEPTANCE",
41 | "sub_status": "NONE",
42 | "timezone": "europe\/amsterdam",
43 | "user_id": 7809,
44 | "monetary_account_profile": null,
45 | "notification_filters": [],
46 | "setting": {
47 | "color": "#FE2851",
48 | "default_avatar_status": "AVATAR_DEFAULT",
49 | "restriction_chat": "ALLOW_INCOMING"
50 | },
51 | "overdraft_limit": {
52 | "currency": "EUR",
53 | "value": "0.00"
54 | },
55 | "all_co_owner": [
56 | {
57 | "alias": {
58 | "uuid": "d2d59347-b5b6-4db0-8c18-9897e23a7a49",
59 | "display_name": "New",
60 | "country": "000",
61 | "avatar": {
62 | "uuid": "cb302add-2dff-4ec9-8a83-e3698809a340",
63 | "image": [
64 | {
65 | "attachment_public_uuid": "a07e6696-4800-4c5d-a30b-671cb1f3c198",
66 | "height": 128,
67 | "width": 128,
68 | "content_type": "image\/jpeg"
69 | }
70 | ],
71 | "anchor_uuid": "d2d59347-b5b6-4db0-8c18-9897e23a7a49"
72 | },
73 | "public_nick_name": "New"
74 | },
75 | "status": "ACCEPTED"
76 | },
77 | {
78 | "alias": {
79 | "uuid": "fba128d6-0819-424f-b5bf-b7cd556a6438",
80 | "display_name": "Drake (Person b)",
81 | "country": "000",
82 | "avatar": {
83 | "uuid": "6e9e8e6b-b6d6-44b9-83c3-ab7150921ab1",
84 | "image": [
85 | {
86 | "attachment_public_uuid": "f3f3c3de-09f3-4523-9bfb-ab33a664610e",
87 | "height": 1024,
88 | "width": 1024,
89 | "content_type": "image\/jpeg"
90 | }
91 | ],
92 | "anchor_uuid": "fba128d6-0819-424f-b5bf-b7cd556a6438"
93 | },
94 | "public_nick_name": "Drake (Person b)"
95 | },
96 | "status": "PENDING"
97 | }
98 | ]
99 | }
100 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/BunqMeTab.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "BUNQME_TAB",
5 | "event_type": "BUNQME_TAB_CREATED",
6 | "object": {
7 | "BunqMeTab": {
8 | "id": 8,
9 | "created": "2017-11-09 11:05:42.364451",
10 | "updated": "2017-11-09 11:05:42.364451",
11 | "time_expiry": "2017-12-09 11:05:42.361442",
12 | "monetary_account_id": 32,
13 | "status": "WAITING_FOR_PAYMENT",
14 | "bunqme_tab_share_url": "https:\/\/bunq.me\/t\/4da1167c-01bc-4a91-a1e1-44a83e4fef2e",
15 | "bunqme_tab_entry": {
16 | "uuid": "4da1167c-01bc-4a91-a1e1-44a83e4fef2e",
17 | "created": "2017-11-09 11:05:42.450462",
18 | "updated": "2017-11-09 11:05:42.450462",
19 | "amount_inquired": {
20 | "currency": "EUR",
21 | "value": "5.00"
22 | },
23 | "status": "WAITING_FOR_PAYMENT",
24 | "description": "Thank you for building this awesome feature!",
25 | "alias": {
26 | "iban": "NL18BUNQ2025104340",
27 | "is_light": false,
28 | "display_name": "B.O. Varb",
29 | "avatar": {
30 | "uuid": "b0005448-e385-4d6c-baef-244c295b524f",
31 | "image": [
32 | {
33 | "attachment_public_uuid": "0c872c0e-1c98-4eea-ba75-a36c6d0a40c7",
34 | "height": 1024,
35 | "width": 1024,
36 | "content_type": "image\/png"
37 | }
38 | ],
39 | "anchor_uuid": null
40 | },
41 | "label_user": {
42 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80",
43 | "display_name": "B.O. Varb",
44 | "country": "NL",
45 | "avatar": {
46 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881",
47 | "image": [
48 | {
49 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89",
50 | "height": 480,
51 | "width": 480,
52 | "content_type": "image\/jpeg"
53 | }
54 | ],
55 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80"
56 | },
57 | "public_nick_name": "Bravo O (nickname)"
58 | },
59 | "country": "NL"
60 | },
61 | "redirect_url": null,
62 | "merchant_available": [
63 | {
64 | "merchant_type": "IDEAL",
65 | "available": true
66 | },
67 | {
68 | "merchant_type": "SOFORT",
69 | "available": true
70 | }
71 | ]
72 | },
73 | "result_inquiries": []
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/Mutation.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "MUTATION",
5 | "event_type": "MUTATION_CREATED",
6 | "object": {
7 | "Payment": {
8 | "id": 108864,
9 | "created": "2017-11-08 11:20:03.950184",
10 | "updated": "2017-11-08 11:20:03.950184",
11 | "monetary_account_id": 213,
12 | "amount": {
13 | "currency": "EUR",
14 | "value": "-24.95"
15 | },
16 | "description": "73d6df4a 680498 Your order #1234567",
17 | "type": "IDEAL",
18 | "merchant_reference": null,
19 | "maturity_date": "2017-11-08",
20 | "alias": {
21 | "iban": "NL86BUNQ2025105541",
22 | "is_light": false,
23 | "display_name": "D. Howard",
24 | "avatar": {
25 | "uuid": "cd028f6d-52f3-4b55-be76-56223b7adeec",
26 | "image": [
27 | {
28 | "attachment_public_uuid": "54b3fbaa-a427-4115-a04d-08372f706a42",
29 | "height": 1023,
30 | "width": 1024,
31 | "content_type": "image\/png"
32 | }
33 | ],
34 | "anchor_uuid": null
35 | },
36 | "label_user": {
37 | "uuid": "0f4540c4-e81d-4aca-ad12-144fb73635a1",
38 | "display_name": "D. Howard",
39 | "country": "NL",
40 | "avatar": {
41 | "uuid": "514aaa28-c2da-42ce-aa76-7b6fd6f528cc",
42 | "image": [
43 | {
44 | "attachment_public_uuid": "14847000-40da-4f63-9137-817683acd980",
45 | "height": 456,
46 | "width": 456,
47 | "content_type": "image\/jpeg"
48 | }
49 | ],
50 | "anchor_uuid": "0f4540c4-e81d-4aca-ad12-144fb73635a1"
51 | },
52 | "public_nick_name": "Dominic (nick) premium \u2728"
53 | },
54 | "country": "NL"
55 | },
56 | "counterparty_alias": {
57 | "iban": null,
58 | "is_light": null,
59 | "display_name": "BoekenGigant",
60 | "label_user": {
61 | "uuid": null,
62 | "display_name": "BoekenGigant",
63 | "country": "NL",
64 | "avatar": null,
65 | "public_nick_name": "BoekenGigant"
66 | },
67 | "avatar": null,
68 | "country": "NL"
69 | },
70 | "attachment": [],
71 | "geolocation": {
72 | "latitude": 52.387862883215,
73 | "longitude": 4.8333778222355,
74 | "altitude": 0,
75 | "radius": 65
76 | },
77 | "batch_id": null,
78 | "conversation": null,
79 | "allow_chat": true,
80 | "scheduled_id": null,
81 | "address_billing": null,
82 | "address_shipping": null
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/bunq/sdk/context/user_context.py:
--------------------------------------------------------------------------------
1 | from bunq.sdk.exception.bunq_exception import BunqException
2 | from bunq.sdk.model.core.bunq_model import BunqModel
3 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, MonetaryAccountBankApiObject, UserApiObject, \
4 | UserPaymentServiceProviderApiObject
5 |
6 |
7 | class UserContext:
8 | _ERROR_UNEXPECTED_USER_INSTANCE = '"{}" is unexpected user instance.'
9 | _ERROR_NO_ACTIVE_MONETARY_ACCOUNT_FOUND = 'No active monetary account found.'
10 | _STATUS_ACTIVE = 'ACTIVE'
11 |
12 | def __init__(self, user_id: int, user: BunqModel) -> None:
13 | self._user_id = user_id
14 | self._user_person = None
15 | self._user_company = None
16 | self._user_api_key = None
17 | self._user_payment_service_provider = None
18 | self._primary_monetary_account = None
19 |
20 | self._set_user(user)
21 |
22 | @staticmethod
23 | def __get_user_object() -> BunqModel:
24 | return UserApiObject.list().value[0].get_referenced_object()
25 |
26 | def _set_user(self, user: BunqModel) -> None:
27 | if isinstance(user, UserPersonApiObject):
28 | self._user_person = user
29 |
30 | elif isinstance(user, UserCompanyApiObject):
31 | self._user_company = user
32 |
33 | elif isinstance(user, UserApiKeyApiObject):
34 | self._user_api_key = user
35 |
36 | elif isinstance(user, UserPaymentServiceProviderApiObject):
37 | self._user_payment_service_provider = user
38 |
39 | else:
40 | raise BunqException(
41 | self._ERROR_UNEXPECTED_USER_INSTANCE.format(user.__class__))
42 |
43 | def init_main_monetary_account(self) -> None:
44 | if self._user_payment_service_provider is not None:
45 | return
46 |
47 | all_monetary_account = MonetaryAccountBankApiObject.list().value
48 |
49 | for account in all_monetary_account:
50 | if account.status == self._STATUS_ACTIVE:
51 | self._primary_monetary_account = account
52 |
53 | return
54 |
55 | raise BunqException(self._ERROR_NO_ACTIVE_MONETARY_ACCOUNT_FOUND)
56 |
57 | @property
58 | def user_id(self) -> int:
59 | return self._user_id
60 |
61 | def is_only_user_person_set(self) -> bool:
62 | return self._user_person is not None \
63 | and self._user_company is None \
64 | and self._user_api_key is None
65 |
66 | def is_only_user_company_set(self) -> bool:
67 | return self._user_company is not None \
68 | and self._user_person is None \
69 | and self._user_api_key is None
70 |
71 | def is_only_user_api_key_set(self) -> bool:
72 | return self._user_api_key is not None \
73 | and self._user_company is None \
74 | and self._user_person is None
75 |
76 | def is_all_user_type_set(self) -> bool:
77 | return self._user_company is not None \
78 | and self._user_person is not None \
79 | and self._user_api_key is not None
80 |
81 | def refresh_user_context(self) -> None:
82 | self._set_user(self.__get_user_object())
83 |
84 | if self._user_payment_service_provider is not None:
85 | return
86 |
87 | self.init_main_monetary_account()
88 |
89 | @property
90 | def user_company(self) -> UserCompanyApiObject:
91 | return self._user_company
92 |
93 | @property
94 | def user_person(self) -> UserPersonApiObject:
95 | return self._user_person
96 |
97 | @property
98 | def user_api_key(self) -> UserApiKeyApiObject:
99 | return self._user_api_key
100 |
101 | @property
102 | def primary_monetary_account(self) -> MonetaryAccountBankApiObject:
103 | return self._primary_monetary_account
104 |
--------------------------------------------------------------------------------
/bunq/sdk/context/session_context.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | from typing import Optional
5 |
6 | from bunq.sdk.exception.bunq_exception import BunqException
7 | from bunq.sdk.model.core.bunq_model import BunqModel
8 | from bunq.sdk.model.core.session_token import SessionToken
9 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject
10 |
11 |
12 | class SessionContext:
13 | """
14 | :type _token: str
15 | :type _expiry_time: datetime.datetime
16 | :type _user_id: int
17 | :type _user_person: UserPersonApiObject|None
18 | :type _user_company: UserCompanyApiObject|None
19 | :type _user_api_key: UserApiKeyApiObject|None
20 | :type _user_payment_service_provider: UserPaymentServiceProviderApiObject|None
21 | """
22 |
23 | # Error constants
24 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null'
25 | _ERROR_UNEXPECTED_USER_INSTANCE = '"{}" is unexpected user instance.'
26 |
27 | @property
28 | def token(self) -> str:
29 | return self._token
30 |
31 | @property
32 | def expiry_time(self) -> datetime.datetime:
33 | return self._expiry_time
34 |
35 | @property
36 | def user_id(self) -> int:
37 | return self._user_id
38 |
39 | @property
40 | def user_person(self) -> Optional[UserPersonApiObject]:
41 | return self._user_person
42 |
43 | @property
44 | def user_company(self) -> Optional[UserCompanyApiObject]:
45 | return self._user_company
46 |
47 | @property
48 | def user_api_key(self) -> Optional[UserApiKeyApiObject]:
49 | return self._user_api_key
50 |
51 | @property
52 | def user_payment_service_provider(self) -> Optional[UserPaymentServiceProviderApiObject]:
53 | return self._user_payment_service_provider
54 |
55 | def __init__(self, token: SessionToken, expiry_time: datetime.datetime, user: BunqModel) -> None:
56 | self._user_person = None
57 | self._user_company = None
58 | self._user_api_key = None
59 | self._user_payment_service_provider = None
60 | self._token = token.token
61 | self._expiry_time = expiry_time
62 | self._user_id = self.__get_user_id(user)
63 | self.__set_user(user)
64 |
65 | def __get_user_id(self, user: BunqModel) -> int:
66 | if isinstance(user, UserPersonApiObject):
67 | return user.id_
68 |
69 | if isinstance(user, UserCompanyApiObject):
70 | return user.id_
71 |
72 | if isinstance(user, UserApiKeyApiObject):
73 | return user.id_
74 |
75 | if isinstance(user, UserPaymentServiceProviderApiObject):
76 | return user.id_
77 |
78 | raise BunqException(self._ERROR_UNEXPECTED_USER_INSTANCE)
79 |
80 | def __set_user(self, user: BunqModel):
81 | if isinstance(user, UserPersonApiObject):
82 | self._user_person = user
83 | elif isinstance(user, UserCompanyApiObject):
84 | self._user_company = user
85 | elif isinstance(user, UserApiKeyApiObject):
86 | self._user_api_key = user
87 | elif isinstance(user, UserPaymentServiceProviderApiObject):
88 | self._user_payment_service_provider = user
89 | else:
90 | raise BunqException(self._ERROR_UNEXPECTED_USER_INSTANCE)
91 |
92 | def get_user_reference(self) -> BunqModel:
93 | if self.user_person is not None:
94 | return self.user_person
95 | elif self.user_company is not None:
96 | return self.user_company
97 | elif self.user_api_key is not None:
98 | return self.user_api_key
99 | elif self.user_payment_service_provider is not None:
100 | return self.user_payment_service_provider
101 | else:
102 | raise BunqException(self._ERROR_ALL_FIELD_IS_NULL)
103 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/session_server.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional
4 |
5 | from bunq.sdk.context.api_context import ApiContext
6 | from bunq.sdk.exception.bunq_exception import BunqException
7 | from bunq.sdk.http.api_client import ApiClient
8 | from bunq.sdk.http.bunq_response import BunqResponse
9 | from bunq.sdk.json import converter
10 | from bunq.sdk.model.core.bunq_model import BunqModel
11 | from bunq.sdk.model.core.id import Id
12 | from bunq.sdk.model.core.session_token import SessionToken
13 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject
14 |
15 |
16 | class SessionServer(BunqModel):
17 | """
18 | :type _id_: Id|None
19 | :type _token: SessionToken|None
20 | :type _user_person: UserPersonApiObject|None
21 | :type _user_company: UserCompanyApiObject|None
22 | :type _user_api_key: UserApiKeyApiObject|None
23 | :type _user_payment_service_provider: UserPaymentServiceProviderApiObject|None
24 | """
25 |
26 | # Endpoint name.
27 | _ENDPOINT_URL_POST = "session-server"
28 |
29 | # Field constants
30 | FIELD_SECRET = "secret"
31 |
32 | # Error constants
33 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null'
34 |
35 | @property
36 | def id_(self) -> Optional[Id]:
37 | return self._id_
38 |
39 | @property
40 | def token(self) -> Optional[SessionToken]:
41 | return self._token
42 |
43 | @property
44 | def user_person(self) -> Optional[UserPersonApiObject]:
45 | return self._user_person
46 |
47 | @property
48 | def user_company(self) -> Optional[UserCompanyApiObject]:
49 | return self._user_company
50 |
51 | @property
52 | def user_api_key(self) -> Optional[UserApiKeyApiObject]:
53 | return self._user_api_key
54 |
55 | @property
56 | def user_payment_service_provider(self) -> Optional[UserPaymentServiceProviderApiObject]:
57 | return self._user_payment_service_provider
58 |
59 | def __init__(self) -> None:
60 | self._user_person = None
61 | self._user_company = None
62 | self._user_api_key = None
63 | self._user_payment_service_provider = None
64 | self._token = None
65 | self._id_ = None
66 |
67 | @classmethod
68 | def create(cls, api_context: ApiContext) -> BunqResponse[SessionServer]:
69 | cls.__init__(cls)
70 | api_client = ApiClient(api_context)
71 | body_bytes = cls.generate_request_body_bytes(api_context.api_key)
72 | response_raw = api_client.post(cls._ENDPOINT_URL_POST, body_bytes, {})
73 |
74 | return cls._from_json_array_nested(response_raw)
75 |
76 | @classmethod
77 | def generate_request_body_bytes(cls, secret: str) -> bytes:
78 | return converter.class_to_json({cls.FIELD_SECRET: secret}).encode()
79 |
80 | def is_all_field_none(self) -> bool:
81 | if self.id_ is not None:
82 | return False
83 | elif self.token is not None:
84 | return False
85 | elif self.user_person is not None:
86 | return False
87 | elif self.user_company is not None:
88 | return False
89 | elif self.user_api_key is not None:
90 | return False
91 | elif self.user_payment_service_provider is not None:
92 | return False
93 | else:
94 | return True
95 |
96 | def get_user_reference(self) -> BunqModel:
97 | if self.user_person is not None:
98 | return self.user_person
99 | elif self.user_company is not None:
100 | return self.user_company
101 | elif self.user_api_key is not None:
102 | return self.user_api_key
103 | elif self.user_payment_service_provider is not None:
104 | return self.user_payment_service_provider
105 | else:
106 | raise BunqException(self._ERROR_ALL_FIELD_IS_NULL)
107 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/oauth_access_token.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, Type
4 |
5 | from bunq import ApiEnvironmentType
6 | from bunq.sdk.context.bunq_context import BunqContext
7 | from bunq.sdk.exception.bunq_exception import BunqException
8 | from bunq.sdk.http.anonymous_api_client import AnonymousApiClient
9 | from bunq.sdk.http.bunq_response import BunqResponse
10 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
11 | from bunq.sdk.http.http_util import HttpUtil
12 | from bunq.sdk.json import converter
13 | from bunq.sdk.model.core.bunq_model import BunqModel
14 | from bunq.sdk.model.core.oauth_grant_type import OauthGrantType
15 | from bunq.sdk.model.generated.endpoint import OauthClient
16 | from bunq.sdk.util.type_alias import T
17 |
18 |
19 | class OauthAccessToken(BunqModel):
20 | # Field constants.
21 | FIELD_GRANT_TYPE = "grant_type"
22 | FIELD_CODE = "code"
23 | FIELD_REDIRECT_URI = "redirect_uri"
24 | FIELD_CLIENT_ID = "client_id"
25 | FIELD_CLIENT_SECRET = "client_secret"
26 |
27 | # Token constants.
28 | TOKEN_URI_FORMAT_SANDBOX = "https://api-oauth.sandbox.bunq.com/v1/token?%s"
29 | TOKEN_URI_FORMAT_PRODUCTION = "https://api.oauth.bunq.com/v1/token?%s"
30 |
31 | # Error constants.
32 | ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type."
33 |
34 | def __init__(self, token: str, token_type: str, state: str = None) -> None:
35 | self._token = token
36 | self._token_type = token_type
37 | self._state = state
38 |
39 | @property
40 | def token(self) -> str:
41 | return self._token
42 |
43 | @property
44 | def token_type(self) -> str:
45 | return self._token_type
46 |
47 | @property
48 | def state(self) -> Optional[str]:
49 | return self._state
50 |
51 | @classmethod
52 | def create(cls,
53 | grant_type: OauthGrantType,
54 | oauth_code: str,
55 | redirect_uri: str,
56 | client: OauthClient) -> OauthAccessToken:
57 | api_client = AnonymousApiClient(BunqContext.api_context())
58 | response_raw = api_client.post(
59 | cls.create_token_uri(grant_type.value, oauth_code, redirect_uri, client),
60 | bytearray(),
61 | {}
62 | )
63 |
64 | return cls.from_json(OauthAccessToken, response_raw).value
65 |
66 | @classmethod
67 | def create_token_uri(cls, grant_type: str, auth_code: str, redirect_uri: str, client: OauthClient) -> str:
68 | all_token_parameter = {
69 | cls.FIELD_GRANT_TYPE: grant_type,
70 | cls.FIELD_CODE: auth_code,
71 | cls.FIELD_REDIRECT_URI: redirect_uri,
72 | cls.FIELD_CLIENT_ID: client.id_,
73 | cls.FIELD_CLIENT_SECRET: client.secret,
74 | }
75 |
76 | return cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_token_parameter))
77 |
78 | def is_all_field_none(self) -> bool:
79 | if self._token is not None:
80 | return False
81 | elif self._token_type is not None:
82 | return False
83 | elif self._state is not None:
84 | return False
85 |
86 | return True
87 |
88 | @classmethod
89 | def from_json(cls, class_of_object: Type[T], response_raw: BunqResponseRaw):
90 | response_item_object = converter.deserialize(class_of_object, response_raw)
91 | response_value = converter.json_to_class(class_of_object, response_item_object)
92 |
93 | return BunqResponse(response_value, response_raw.headers)
94 |
95 | @classmethod
96 | def determine_auth_uri_format(cls) -> str:
97 | environment_type = BunqContext.api_context().environment_type
98 |
99 | if ApiEnvironmentType.PRODUCTION == environment_type:
100 | return cls.TOKEN_URI_FORMAT_PRODUCTION
101 |
102 | if ApiEnvironmentType.SANDBOX == environment_type:
103 | return cls.TOKEN_URI_FORMAT_SANDBOX
104 |
105 | raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED)
106 |
--------------------------------------------------------------------------------
/tests/context/test_psd2_context.py:
--------------------------------------------------------------------------------
1 | import os
2 | import unittest
3 |
4 | from bunq import ApiEnvironmentType
5 | from bunq.sdk.context.api_context import ApiContext
6 | from bunq.sdk.context.bunq_context import BunqContext
7 | from bunq.sdk.json import converter
8 | from bunq.sdk.model.generated.endpoint import OauthClientApiObject
9 | from tests.bunq_test import BunqSdkTestCase
10 |
11 | class TestPsd2Context(unittest.TestCase):
12 | """
13 | Tests:
14 | Psd2Context
15 | """
16 |
17 | _FILE_TEST_CONFIGURATION = '/bunq-psd2-test.conf'
18 | _FILE_TEST_OAUTH = '/bunq-oauth-psd2-test.conf'
19 |
20 | _FILE_TEST_CERTIFICATE = '/certificate.pem'
21 | _FILE_TEST_CERTIFICATE_CHAIN = '/certificate.pem'
22 | _FILE_TEST_PRIVATE_KEY = '/key.pem'
23 |
24 | _TEST_DEVICE_DESCRIPTION = 'PSD2TestDevice'
25 |
26 | @classmethod
27 | def setUpClass(cls) -> None:
28 | cls._FILE_MODE_READ = ApiContext._FILE_MODE_READ
29 | cls._FILE_TEST_CONFIGURATION_PATH_FULL = (
30 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CONFIGURATION
31 | )
32 | cls._FILE_TEST_OAUTH_PATH_FULL = (
33 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_OAUTH
34 | )
35 | cls._FILE_TEST_CERTIFICATE_PATH_FULL = (
36 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CERTIFICATE
37 | )
38 | cls._FILE_TEST_CERTIFICATE_CHAIN_PATH_FULL = (
39 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CERTIFICATE_CHAIN
40 | )
41 | cls._FILE_TEST_PRIVATE_KEY_PATH_FULL = (
42 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_PRIVATE_KEY
43 | )
44 | cls.setup_test_data()
45 |
46 | @classmethod
47 | def setup_test_data(cls) -> None:
48 | if not os.path.isfile(cls._FILE_TEST_CONFIGURATION_PATH_FULL):
49 | try:
50 | BunqContext.load_api_context(cls._create_api_context())
51 | except FileNotFoundError:
52 | return
53 |
54 | api_context = ApiContext.restore(cls._FILE_TEST_CONFIGURATION_PATH_FULL)
55 | BunqContext.load_api_context(api_context)
56 |
57 | def test_create_psd2_context(self) -> None:
58 | if os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL):
59 | return
60 |
61 | try:
62 | api_context = self._create_api_context()
63 | BunqContext.load_api_context(api_context)
64 |
65 | self.assertTrue(os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL))
66 |
67 | except AssertionError:
68 | raise AssertionError
69 |
70 | def test_create_oauth_client(self) -> None:
71 | if os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL):
72 | return
73 |
74 | try:
75 | client_id = OauthClientApiObject.create().value
76 | oauth_client = OauthClientApiObject.get(client_id).value
77 |
78 | self.assertIsNotNone(oauth_client)
79 |
80 | serialized_client = converter.class_to_json(oauth_client)
81 |
82 | file = open(self._FILE_TEST_OAUTH_PATH_FULL, ApiContext._FILE_MODE_WRITE)
83 | file.write(serialized_client)
84 | file.close()
85 |
86 | self.assertTrue(os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL))
87 |
88 | except AssertionError:
89 | raise AssertionError
90 |
91 | @classmethod
92 | def _create_api_context(cls) -> ApiContext:
93 | with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_:
94 | certificate = file_.read()
95 |
96 | with open(cls._FILE_TEST_PRIVATE_KEY_PATH_FULL, cls._FILE_MODE_READ) as file_:
97 | private_key = file_.read()
98 |
99 | with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_:
100 | all_certificate_chain = file_.read()
101 |
102 | api_context = ApiContext.create_for_psd2(
103 | ApiEnvironmentType.SANDBOX,
104 | certificate,
105 | private_key,
106 | [all_certificate_chain],
107 | cls._TEST_DEVICE_DESCRIPTION
108 | )
109 |
110 | api_context.save(cls._FILE_TEST_CONFIGURATION_PATH_FULL)
111 |
112 | return api_context
113 |
--------------------------------------------------------------------------------
/tests/bunq_test.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import unittest
4 | from typing import AnyStr
5 |
6 | from bunq.sdk.context.api_context import ApiContext
7 | from bunq.sdk.context.bunq_context import BunqContext
8 | from bunq.sdk.exception.bunq_exception import BunqException
9 | from bunq.sdk.http.api_client import ApiClient
10 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject, RequestInquiryApiObject, AttachmentPublicApiObject, AvatarApiObject
11 | from bunq.sdk.model.generated.object_ import AmountObject, PointerObject
12 | from bunq.sdk.util import util
13 |
14 |
15 | class BunqSdkTestCase(unittest.TestCase):
16 | """
17 | :type _second_monetary_account: MonetaryAccountBankApiObject
18 | """
19 |
20 | # Error constants.
21 | __ERROR_COULD_NOT_DETERMINE_USER = 'Could not determine user alias.'
22 |
23 | # Name of bunq config file
24 | _FILENAME_BUNQ_CONFIG = "/bunq-test.conf"
25 |
26 | # Device description used for python tests
27 | _DEVICE_DESCRIPTION = 'Python test device'
28 |
29 | _PATH_ATTACHMENT = 'tests/assets/'
30 | _READ_BYTES = "rb"
31 | _ATTACHMENT_PATH_IN = 'vader.png'
32 | _CONTENT_TYPE = 'image/png'
33 | _ATTACHMENT_DESCRIPTION = 'SDK python test'
34 | _FIRST_INDEX = 0
35 |
36 | __SPENDING_MONEY_AMOUNT = '500'
37 | __CURRENCY_EUR = 'EUR'
38 | _POINTER_EMAIL = 'EMAIL'
39 | __SPENDING_MONEY_RECIPIENT = 'sugardaddy@bunq.com'
40 | __REQUEST_SPENDING_DESCRIPTION = 'sdk python test, thanks daddy <3'
41 |
42 | __SECOND_MONETARY_ACCOUNT_DESCRIPTION = 'test account python'
43 |
44 | _EMAIL_BRAVO = 'bravo@bunq.com'
45 |
46 | __TIME_OUT_AUTO_ACCEPT_SPENDING_MONEY = 2
47 |
48 | _second_monetary_account = None
49 |
50 | @classmethod
51 | def setUpClass(cls):
52 | BunqContext.load_api_context(cls._get_api_context())
53 |
54 | def setUp(self):
55 | self.__set_second_monetary_account()
56 | self.__request_spending_money()
57 | time.sleep(self.__TIME_OUT_AUTO_ACCEPT_SPENDING_MONEY)
58 | BunqContext.user_context().refresh_user_context()
59 |
60 | def __set_second_monetary_account(self):
61 | response = MonetaryAccountBankApiObject.create(
62 | self.__CURRENCY_EUR,
63 | self.__SECOND_MONETARY_ACCOUNT_DESCRIPTION
64 | )
65 |
66 | self._second_monetary_account = MonetaryAccountBankApiObject.get(
67 | response.value
68 | ).value
69 |
70 | def __request_spending_money(self):
71 | RequestInquiryApiObject.create(
72 | AmountObject(self.__SPENDING_MONEY_AMOUNT, self.__CURRENCY_EUR),
73 | PointerObject(self._POINTER_EMAIL, self.__SPENDING_MONEY_RECIPIENT),
74 | self.__REQUEST_SPENDING_DESCRIPTION,
75 | False
76 | )
77 | RequestInquiryApiObject.create(
78 | AmountObject(self.__SPENDING_MONEY_AMOUNT, self.__CURRENCY_EUR),
79 | PointerObject(self._POINTER_EMAIL, self.__SPENDING_MONEY_RECIPIENT),
80 | self.__REQUEST_SPENDING_DESCRIPTION,
81 | False,
82 | self._second_monetary_account.id_
83 | )
84 |
85 | @classmethod
86 | def _get_api_context(cls) -> ApiContext:
87 | return util.automatic_sandbox_install()
88 |
89 | def _get_pointer_bravo(self) -> PointerObject:
90 | return PointerObject(self._POINTER_EMAIL, self._EMAIL_BRAVO)
91 |
92 | def _get_alias_second_account(self) -> PointerObject:
93 | return self._second_monetary_account.alias[self._FIRST_INDEX]
94 |
95 | @staticmethod
96 | def _get_directory_test_root():
97 | return os.path.dirname(os.path.abspath(__file__))
98 |
99 | @property
100 | def _attachment_contents(self) -> AnyStr:
101 | with open(
102 | self._get_directory_test_root() +
103 | self._PATH_ATTACHMENT +
104 | self._ATTACHMENT_PATH_IN,
105 | self._READ_BYTES
106 | ) as file:
107 | return file.read()
108 |
109 | @property
110 | def alias_first(self) -> PointerObject:
111 | if BunqContext.user_context().is_only_user_company_set():
112 | return BunqContext.user_context().user_company.alias[self._FIRST_INDEX]
113 |
114 | if BunqContext.user_context().is_only_user_person_set():
115 | return BunqContext.user_context().user_person.alias[self._FIRST_INDEX]
116 |
117 | raise BunqException(self.__ERROR_COULD_NOT_DETERMINE_USER)
118 |
--------------------------------------------------------------------------------
/bunq/sdk/exception/exception_factory.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from bunq.sdk.exception.api_exception import ApiException
4 | from bunq.sdk.exception.bad_request_exception import BadRequestException
5 | from bunq.sdk.exception.forbidden_exception import ForbiddenException
6 | from bunq.sdk.exception.method_not_allowed_exception import MethodNotAllowedException
7 | from bunq.sdk.exception.not_found_exception import NotFoundException
8 | from bunq.sdk.exception.please_contact_bunq_exception import PleaseContactBunqException
9 | from bunq.sdk.exception.too_many_requests_exception import TooManyRequestsException
10 | from bunq.sdk.exception.unauthorized_exception import UnauthorizedException
11 | from bunq.sdk.exception.unknown_api_error_exception import UnknownApiErrorException
12 |
13 |
14 | class ExceptionFactory:
15 | # Error response code constants
16 | _HTTP_RESPONSE_CODE_BAD_REQUEST = 400
17 | _HTTP_RESPONSE_CODE_UNAUTHORIZED = 401
18 | _HTTP_RESPONSE_CODE_FORBIDDEN = 403
19 | _HTTP_RESPONSE_CODE_NOT_FOUND = 404
20 | _HTTP_RESPONSE_CODE_METHOD_NOT_ALLOWED = 405
21 | _HTTP_RESPONSE_CODE_TOO_MANY_REQUESTS = 429
22 | _HTTP_RESPONSE_CODE_INTERNAL_SERVER_ERROR = 500
23 |
24 | # Constants for formatting messages
25 | _FORMAT_RESPONSE_CODE_LINE = 'HTTP Response Code: {}'
26 | _FORMAT_RESPONSE_ID_LINE = 'The response id to help bunq debug: {}'
27 | _FORMAT_ERROR_MESSAGE_LINE = 'Error message: {}'
28 | _GLUE_ERROR_MESSAGE_NEW_LINE = '\n'
29 | _GLUE_ERROR_MESSAGE_STRING_EMPTY = ''
30 |
31 | @classmethod
32 | def create_exception_for_response(
33 | cls,
34 | response_code: int,
35 | messages: List[str],
36 | response_id: str
37 | ) -> ApiException:
38 | """
39 |
40 | :return: The exception according to the status code.
41 | """
42 |
43 | error_message = cls._generate_message_error(
44 | response_code,
45 | messages,
46 | response_id
47 | )
48 |
49 | if response_code == cls._HTTP_RESPONSE_CODE_BAD_REQUEST:
50 | return BadRequestException(
51 | error_message,
52 | response_code,
53 | response_id
54 | )
55 | if response_code == cls._HTTP_RESPONSE_CODE_UNAUTHORIZED:
56 | return UnauthorizedException(
57 | error_message,
58 | response_code,
59 | response_id
60 | )
61 | if response_code == cls._HTTP_RESPONSE_CODE_FORBIDDEN:
62 | return ForbiddenException(
63 | error_message,
64 | response_code,
65 | response_id
66 | )
67 | if response_code == cls._HTTP_RESPONSE_CODE_NOT_FOUND:
68 | return NotFoundException(
69 | error_message,
70 | response_code,
71 | response_id
72 | )
73 | if response_code == cls._HTTP_RESPONSE_CODE_METHOD_NOT_ALLOWED:
74 | return MethodNotAllowedException(
75 | error_message,
76 | response_code,
77 | response_id
78 | )
79 | if response_code == cls._HTTP_RESPONSE_CODE_TOO_MANY_REQUESTS:
80 | return TooManyRequestsException(
81 | error_message,
82 | response_code,
83 | response_id
84 | )
85 | if response_code == cls._HTTP_RESPONSE_CODE_INTERNAL_SERVER_ERROR:
86 | return PleaseContactBunqException(
87 | error_message,
88 | response_code,
89 | response_id
90 | )
91 |
92 | return UnknownApiErrorException(
93 | error_message,
94 | response_code,
95 | response_id
96 | )
97 |
98 | @classmethod
99 | def _generate_message_error(cls,
100 | response_code: int,
101 | messages: List[str],
102 | response_id: str) -> str:
103 | line_response_code = cls._FORMAT_RESPONSE_CODE_LINE.format(response_code)
104 | line_response_id = cls._FORMAT_RESPONSE_ID_LINE.format(response_id)
105 | line_error_message = cls._FORMAT_ERROR_MESSAGE_LINE.format(cls._GLUE_ERROR_MESSAGE_STRING_EMPTY.join(messages))
106 |
107 | return cls._glue_all_error_message([line_response_code, line_response_id, line_error_message])
108 |
109 | @classmethod
110 | def _glue_all_error_message(cls, messages: List[str]) -> str:
111 | return cls._GLUE_ERROR_MESSAGE_NEW_LINE.join(messages)
112 |
--------------------------------------------------------------------------------
/tests/context/test_api_context.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | from bunq.sdk.context.api_context import ApiContext
5 | from bunq.sdk.context.bunq_context import BunqContext
6 | from bunq.sdk.json import converter
7 | from tests.bunq_test import BunqSdkTestCase
8 |
9 | class TestApiContext(BunqSdkTestCase):
10 | """
11 | Tests:
12 | ApiContext
13 | """
14 |
15 | _TMP_FILE_PATH = '/assets/context-save-test.conf'
16 |
17 | __FIELD_SESSION_CONTEXT = 'session_context'
18 | __FIELD_EXPIRE_TIME = 'expiry_time'
19 | __TIME_STAMP_IN_PAST = '2000-04-07 19:50:43.839717'
20 |
21 | @classmethod
22 | def setUpClass(cls) -> None:
23 | super().setUpClass()
24 | cls._FILE_MODE_READ = ApiContext._FILE_MODE_READ
25 | cls._API_CONTEXT: ApiContext = cls._get_api_context()
26 | cls._TMP_FILE_PATH_FULL = (cls._get_directory_test_root() + cls._TMP_FILE_PATH)
27 |
28 | def test_api_context_save(self) -> None:
29 | """
30 | Converts an ApiContext to JSON data, saves the same ApiContext to a
31 | temporary file, and compares whether the JSON data is equal to the
32 | data in the file.
33 |
34 | Removes the temporary file before assertion.
35 | """
36 |
37 | context_json = converter.class_to_json(self._API_CONTEXT)
38 |
39 | self._API_CONTEXT.save(self._TMP_FILE_PATH_FULL)
40 |
41 | with open(self._TMP_FILE_PATH_FULL, self._FILE_MODE_READ) as file_:
42 | context_retrieved = file_.read()
43 |
44 | os.remove(self._TMP_FILE_PATH_FULL)
45 |
46 | self.assertEqual(context_retrieved, context_json)
47 |
48 | def test_api_context_restore(self) -> None:
49 | """
50 | Saves an ApiContext to a temporary file, restores an ApiContext from
51 | that file, and compares whether the api_keys, tokens, and environment
52 | types are equal in the ApiContext and the restored ApiContext.
53 |
54 | Removes the temporary file before assertion.
55 | """
56 |
57 | self._API_CONTEXT.save(self._TMP_FILE_PATH_FULL)
58 | api_context_restored = ApiContext.restore(self._TMP_FILE_PATH_FULL)
59 |
60 | os.remove(self._TMP_FILE_PATH_FULL)
61 |
62 | self.assertEqual(api_context_restored, self._API_CONTEXT)
63 |
64 | def test_api_context_save_json(self):
65 | """
66 | Converts an ApiContext to JSON data, saves the ApiContext using the
67 | ApiContext.save() function with the to_JSON flag set to True, and
68 | compares whether the JSON data equals the returned JSON data from the
69 | ApiContext.save() function.
70 | """
71 |
72 | context_json = converter.class_to_json(self._API_CONTEXT)
73 | context_saved = self._API_CONTEXT.to_json()
74 |
75 | self.assertEqual(context_saved, context_json)
76 |
77 | def test_api_context_restore_json(self):
78 | """
79 | Saves an ApiContext with the ApiContext.save() function with the
80 | to_JSON flag set to True, restores an ApiContext from the JSON data
81 | returned from the ApiContext.save() function, and checks that the
82 | api_key, token, and environment type variables of the restored
83 | ApiContext are equal to the respective variables in the original
84 | ApiContext.
85 | """
86 |
87 | context_json = self._API_CONTEXT.to_json()
88 | api_context_restored = self._API_CONTEXT.from_json(context_json)
89 |
90 | self.assertEqual(api_context_restored, self._API_CONTEXT)
91 |
92 | def test_auto_bunq_context_update(self):
93 | """
94 | Tests the auto update of BunqContext.
95 | """
96 |
97 | api_context = BunqContext.api_context()
98 | api_context_json = json.loads(api_context.to_json())
99 |
100 | api_context_json[self.__FIELD_SESSION_CONTEXT][self.__FIELD_EXPIRE_TIME] = self.__TIME_STAMP_IN_PAST
101 |
102 | expired_api_context = ApiContext.from_json(json.dumps(api_context_json))
103 |
104 | self.assertNotEqual(api_context.session_context.expiry_time, expired_api_context.session_context.expiry_time)
105 | self.assertEqual(BunqContext.api_context().session_context.expiry_time, api_context.session_context.expiry_time)
106 |
107 | BunqContext.update_api_context(expired_api_context)
108 | BunqContext.user_context().refresh_user_context()
109 |
110 | self.assertNotEqual(
111 | BunqContext.api_context().session_context.expiry_time,
112 | api_context.session_context.expiry_time
113 | )
114 | self.assertFalse(BunqContext.api_context().ensure_session_active())
115 |
--------------------------------------------------------------------------------
/bunq/sdk/json/session_server_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Type, List
2 |
3 | from bunq.sdk.exception.bunq_exception import BunqException
4 | from bunq.sdk.json import converter
5 | from bunq.sdk.model.core.id import Id
6 | from bunq.sdk.model.core.session_server import SessionServer
7 | from bunq.sdk.model.core.session_token import SessionToken
8 | from bunq.sdk.model.generated.endpoint import UserCompanyApiObject, UserPersonApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject
9 |
10 |
11 | class SessionServerAdapter(converter.JsonAdapter):
12 | # Error constants.
13 | _ERROR_COULD_NOT_DETERMINE_USER = 'Could not determine user.'
14 |
15 | # Id constants
16 | _ATTRIBUTE_ID = '_id_'
17 | _INDEX_ID = 0
18 | _FIELD_ID = 'Id'
19 |
20 | # Token constants
21 | _ATTRIBUTE_TOKEN = '_token'
22 | _INDEX_TOKEN = 1
23 | _FIELD_TOKEN = 'Token'
24 |
25 | # User constants
26 | _INDEX_USER = 2
27 |
28 | # UserCompany constants
29 | _ATTRIBUTE_USER_COMPANY = '_user_company'
30 | _FIELD_USER_COMPANY = 'UserCompany'
31 |
32 | # UserPerson constants
33 | _ATTRIBUTE_USER_PERSON = '_user_person'
34 | _FIELD_USER_PERSON = 'UserPerson'
35 |
36 | # UserApiKey constants
37 | _ATTRIBUTE_USER_API_KEY = '_user_api_key'
38 | _FIELD_USER_API_KEY = 'UserApiKey'
39 |
40 | # UserPaymentServiceProvider constants
41 | _ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER = '_user_payment_service_provider'
42 | _FIELD_USER_PAYMENT_SERVER_PROVIDER = 'UserPaymentServiceProvider'
43 |
44 | @classmethod
45 | def deserialize(cls,
46 | target_class: Type[SessionServer],
47 | array: List) -> SessionServer:
48 | session_server = target_class.__new__(target_class)
49 | session_server.__dict__ = {
50 | cls._ATTRIBUTE_ID: converter.deserialize(
51 | Id,
52 | array[cls._INDEX_ID][cls._FIELD_ID]
53 | ),
54 | cls._ATTRIBUTE_TOKEN: converter.deserialize(
55 | SessionToken,
56 | array[cls._INDEX_TOKEN][cls._FIELD_TOKEN]
57 | ),
58 | cls._ATTRIBUTE_USER_COMPANY: None,
59 | cls._ATTRIBUTE_USER_PERSON: None,
60 | cls._ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER: None,
61 | }
62 |
63 | user_dict_wrapped = array[cls._INDEX_USER]
64 |
65 | if cls._FIELD_USER_COMPANY in user_dict_wrapped:
66 | session_server.__dict__[cls._ATTRIBUTE_USER_COMPANY] = \
67 | converter.deserialize(
68 | UserCompanyApiObject,
69 | user_dict_wrapped[cls._FIELD_USER_COMPANY]
70 | )
71 | elif cls._FIELD_USER_PERSON in user_dict_wrapped:
72 | session_server.__dict__[cls._ATTRIBUTE_USER_PERSON] = \
73 | converter.deserialize(
74 | UserPersonApiObject,
75 | user_dict_wrapped[cls._FIELD_USER_PERSON]
76 | )
77 | elif cls._FIELD_USER_API_KEY in user_dict_wrapped:
78 | session_server.__dict__[cls._ATTRIBUTE_USER_API_KEY] = \
79 | converter.deserialize(
80 | UserApiKeyApiObject,
81 | user_dict_wrapped[cls._FIELD_USER_API_KEY]
82 | )
83 | elif cls._FIELD_USER_PAYMENT_SERVER_PROVIDER in user_dict_wrapped:
84 | session_server.__dict__[cls._ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER] = \
85 | converter.deserialize(
86 | UserPaymentServiceProviderApiObject,
87 | user_dict_wrapped[cls._FIELD_USER_PAYMENT_SERVER_PROVIDER]
88 | )
89 | else:
90 | raise BunqException(cls._ERROR_COULD_NOT_DETERMINE_USER)
91 |
92 | return session_server
93 |
94 | @classmethod
95 | def serialize(cls, session_server: SessionServer) -> List:
96 | return [
97 | {cls._FIELD_ID: converter.serialize(session_server.id_)},
98 | {cls._FIELD_TOKEN: converter.serialize(session_server.token)},
99 | {
100 | cls._FIELD_USER_COMPANY:
101 | converter.serialize(session_server.user_company),
102 | },
103 | {
104 | cls._FIELD_USER_PERSON:
105 | converter.serialize(session_server.user_person),
106 | },
107 | {
108 | cls._FIELD_USER_API_KEY:
109 | converter.serialize(session_server.user_api_key),
110 | },
111 | {
112 | cls._FIELD_USER_PAYMENT_SERVER_PROVIDER:
113 | converter.serialize(session_server.user_payment_service_provider),
114 | },
115 | ]
116 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/ShareInviteBankInquiry.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "SHARE",
5 | "event_type": "SHARE_INVITE_BANK_INQUIRY_ACCEPTED",
6 | "object": {
7 | "ShareInviteBankInquiry": {
8 | "id": 1,
9 | "created": "2017-07-20 02:32:32.074527",
10 | "updated": "2017-07-20 02:32:32.074527",
11 | "alias": {
12 | "iban": "NL59BUNQ2025104669",
13 | "is_light": false,
14 | "display_name": "Alpha Corp.",
15 | "avatar": {
16 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a",
17 | "image": [
18 | {
19 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439",
20 | "height": 1024,
21 | "width": 1024,
22 | "content_type": "image\/png"
23 | }
24 | ],
25 | "anchor_uuid": null
26 | },
27 | "label_user": {
28 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3",
29 | "display_name": "Alpha Corp.",
30 | "country": "NL",
31 | "avatar": {
32 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9",
33 | "image": [
34 | {
35 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a",
36 | "height": 640,
37 | "width": 640,
38 | "content_type": "image\/png"
39 | }
40 | ],
41 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3"
42 | },
43 | "public_nick_name": "Alpha Corp."
44 | },
45 | "country": "NL"
46 | },
47 | "user_alias_created": {
48 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3",
49 | "display_name": "Alpha Corp.",
50 | "country": "NL",
51 | "avatar": {
52 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9",
53 | "image": [
54 | {
55 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a",
56 | "height": 640,
57 | "width": 640,
58 | "content_type": "image\/png"
59 | }
60 | ],
61 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3"
62 | },
63 | "public_nick_name": "Alpha Corp."
64 | },
65 | "user_alias_revoked": null,
66 | "counter_user_alias": {
67 | "uuid": "688e4bdc-ec27-4d8e-942b-3aee7b5d15e9",
68 | "display_name": "Echo (nickname)",
69 | "country": "000",
70 | "avatar": {
71 | "uuid": "3da21898-1535-43c9-9023-83333e776b8b",
72 | "image": [
73 | {
74 | "attachment_public_uuid": "6bb2b5d6-faee-4d5d-8284-af026bde3640",
75 | "height": 480,
76 | "width": 480,
77 | "content_type": "image\/jpeg"
78 | }
79 | ],
80 | "anchor_uuid": "688e4bdc-ec27-4d8e-942b-3aee7b5d15e9"
81 | },
82 | "public_nick_name": "Echo (nickname)"
83 | },
84 | "monetary_account_id": 46,
85 | "draft_share_invite_bank_id": null,
86 | "share_type": "STANDARD",
87 | "status": "PENDING",
88 | "share_detail": {
89 | "ShareDetailPayment": {
90 | "view_balance": true,
91 | "view_old_events": true,
92 | "view_new_events": true,
93 | "budget": null,
94 | "make_payments": true
95 | }
96 | },
97 | "start_date": "2017-07-20 02:34:05.864029",
98 | "end_date": "2017-11-11 02:34:05.864029"
99 | }
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/RequestResponse.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "REQUEST",
5 | "event_type": "REQUEST_INQUIRY_ACCEPTED",
6 | "object": {
7 | "RequestResponse": {
8 | "id": 2,
9 | "created": "2017-07-13 21:44:31.840439",
10 | "updated": "2017-07-13 21:44:31.840439",
11 | "time_responded": null,
12 | "time_expiry": null,
13 | "monetary_account_id": 36,
14 | "amount_inquired": {
15 | "currency": "EUR",
16 | "value": "3.31"
17 | },
18 | "amount_responded": null,
19 | "status": "PENDING",
20 | "description": "Test request inquiry to NL15BUNQ2025104200",
21 | "alias": {
22 | "iban": "NL15BUNQ2025104200",
23 | "is_light": false,
24 | "display_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)",
25 | "avatar": {
26 | "uuid": "11ab9e94-a90e-4334-9e70-b81ad7c4dc0a",
27 | "image": [
28 | {
29 | "attachment_public_uuid": "423b6870-a938-41d4-812b-f9b090e03d07",
30 | "height": 1024,
31 | "width": 1024,
32 | "content_type": "image\/png"
33 | }
34 | ],
35 | "anchor_uuid": null
36 | },
37 | "label_user": {
38 | "uuid": "e3281b2c-d552-49b4-b575-33b87cfee463",
39 | "display_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)",
40 | "country": "NL",
41 | "avatar": {
42 | "uuid": "a94240e9-360f-4097-9caa-7974946de533",
43 | "image": [
44 | {
45 | "attachment_public_uuid": "4f25125b-ce8b-43e9-b726-317c9eb0fd46",
46 | "height": 480,
47 | "width": 480,
48 | "content_type": "image\/jpeg"
49 | }
50 | ],
51 | "anchor_uuid": "e3281b2c-d552-49b4-b575-33b87cfee463"
52 | },
53 | "public_nick_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)"
54 | },
55 | "country": "NL"
56 | },
57 | "counterparty_alias": {
58 | "iban": "NL59BUNQ2025104669",
59 | "is_light": false,
60 | "display_name": "Alpha Corp.",
61 | "avatar": {
62 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a",
63 | "image": [
64 | {
65 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439",
66 | "height": 1024,
67 | "width": 1024,
68 | "content_type": "image\/png"
69 | }
70 | ],
71 | "anchor_uuid": null
72 | },
73 | "label_user": {
74 | "uuid|": "2711664e-885e-4b5c-bf2c-581ba39061d3",
75 | "display_name": "Alpha Corp.",
76 | "country": "NL",
77 | "avatar": {
78 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9",
79 | "image": [
80 | {
81 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a",
82 | "height": 640,
83 | "width": 640,
84 | "content_type": "image\/png"
85 | }
86 | ],
87 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3"
88 | },
89 | "public_nick_name": "Alpha Corp."
90 | },
91 | "country": "NL"
92 | },
93 | "attachment": null,
94 | "minimum_age": null,
95 | "require_address": null,
96 | "geolocation": null,
97 | "type": "INTERNAL",
98 | "sub_type": "NONE",
99 | "redirect_url": null,
100 | "address_billing": null,
101 | "address_shipping": null,
102 | "conversation": null,
103 | "allow_chat": true,
104 | "eligible_whitelist_id": null
105 | }
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/tests/http/test_pagination.py:
--------------------------------------------------------------------------------
1 | from bunq import Pagination
2 | from bunq.sdk.exception.bunq_exception import BunqException
3 | from tests.bunq_test import BunqSdkTestCase
4 |
5 |
6 | class TestPagination(BunqSdkTestCase):
7 | """
8 | Tests:
9 | Pagination
10 | """
11 |
12 | _PAGINATION_OLDER_ID_CUSTOM = 1
13 | _PAGINATION_NEWER_ID_CUSTOM = 2
14 | _PAGINATION_FUTURE_ID_CUSTOM = 3
15 | _PAGINATION_COUNT_CUSTOM = 5
16 |
17 | def test_get_url_params_count_only(self):
18 | pagination = self._create_pagination_with_all_properties_set()
19 | url_params_count_only_expected = {
20 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM),
21 | }
22 |
23 | self.assertEqual(url_params_count_only_expected,
24 | pagination.url_params_count_only)
25 |
26 | def _create_pagination_with_all_properties_set(self) -> Pagination:
27 | pagination = Pagination()
28 | pagination.older_id = self._PAGINATION_OLDER_ID_CUSTOM
29 | pagination.newer_id = self._PAGINATION_NEWER_ID_CUSTOM
30 | pagination.future_id = self._PAGINATION_FUTURE_ID_CUSTOM
31 | pagination.count = self._PAGINATION_COUNT_CUSTOM
32 |
33 | return pagination
34 |
35 | def test_get_url_params_previous_page(self):
36 | pagination = self._create_pagination_with_all_properties_set()
37 | url_params_previous_page_expected = {
38 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM),
39 | Pagination.PARAM_OLDER_ID: str(self._PAGINATION_OLDER_ID_CUSTOM),
40 | }
41 |
42 | self.assertTrue(pagination.has_previous_page())
43 | self.assertEqual(url_params_previous_page_expected, pagination.url_params_previous_page)
44 |
45 | def test_get_url_params_previous_page_no_count(self):
46 | pagination = self._create_pagination_with_all_properties_set()
47 | pagination.count = None
48 | url_params_previous_page_expected = {
49 | Pagination.PARAM_OLDER_ID: str(self._PAGINATION_OLDER_ID_CUSTOM),
50 | }
51 |
52 | self.assertTrue(pagination.has_previous_page())
53 | self.assertEqual(url_params_previous_page_expected, pagination.url_params_previous_page)
54 |
55 | def test_get_url_params_next_page_newer(self):
56 | pagination = self._create_pagination_with_all_properties_set()
57 | url_params_next_page_expected = {
58 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM),
59 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_NEWER_ID_CUSTOM),
60 | }
61 |
62 | self.assertTrue(pagination.has_next_page_assured())
63 | self.assertEqual(url_params_next_page_expected,
64 | pagination.url_params_next_page)
65 |
66 | def test_get_url_params_next_page_newer_no_count(self):
67 | pagination = self._create_pagination_with_all_properties_set()
68 | pagination.count = None
69 | url_params_next_page_expected = {
70 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_NEWER_ID_CUSTOM),
71 | }
72 |
73 | self.assertTrue(pagination.has_next_page_assured())
74 | self.assertEqual(url_params_next_page_expected,
75 | pagination.url_params_next_page)
76 |
77 | def test_get_url_params_next_page_future(self):
78 | pagination = self._create_pagination_with_all_properties_set()
79 | pagination.newer_id = None
80 | url_params_next_page_expected = {
81 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM),
82 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_FUTURE_ID_CUSTOM),
83 | }
84 |
85 | self.assertFalse(pagination.has_next_page_assured())
86 | self.assertEqual(url_params_next_page_expected,
87 | pagination.url_params_next_page)
88 |
89 | def test_get_url_params_next_page_future_no_count(self):
90 | pagination = self._create_pagination_with_all_properties_set()
91 | pagination.newer_id = None
92 | pagination.count = None
93 | url_params_next_page_expected = {
94 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_FUTURE_ID_CUSTOM),
95 | }
96 |
97 | self.assertFalse(pagination.has_next_page_assured())
98 | self.assertEqual(url_params_next_page_expected,
99 | pagination.url_params_next_page)
100 |
101 | def test_get_url_params_prev_page_from_pagination_with_no_prev_page(self):
102 | pagination = self._create_pagination_with_all_properties_set()
103 | pagination.older_id = None
104 |
105 | def access_url_params_previous_page():
106 | _ = pagination.url_params_previous_page
107 |
108 | self.assertFalse(pagination.has_previous_page())
109 | self.assertRaises(BunqException,
110 | access_url_params_previous_page)
111 |
112 | def test_get_url_params_next_page_from_pagination_with_no_next_page(self):
113 | pagination = self._create_pagination_with_all_properties_set()
114 | pagination.newer_id = None
115 | pagination.future_id = None
116 |
117 | def access_url_params_next_page():
118 | _ = pagination.url_params_next_page
119 |
120 | self.assertRaises(BunqException, access_url_params_next_page)
121 |
--------------------------------------------------------------------------------
/bunq/sdk/model/core/bunq_model.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing
4 | from typing import Dict, List
5 |
6 | from bunq.sdk.util.type_alias import T
7 | from bunq.sdk.http.bunq_response import BunqResponse
8 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
9 | from bunq.sdk.json import converter
10 |
11 | if typing.TYPE_CHECKING:
12 | from bunq.sdk.context.api_context import ApiContext
13 |
14 |
15 | class BunqModel:
16 | # Field constants
17 | _FIELD_RESPONSE = 'Response'
18 | _FIELD_PAGINATION = 'Pagination'
19 | _FIELD_ID = 'Id'
20 | _FIELD_UUID = 'Uuid'
21 |
22 | # The very first index of an array
23 | _INDEX_FIRST = 0
24 |
25 | __STRING_FORMAT_EMPTY = ''
26 | __STRING_FORMAT_FIELD_FOR_REQUEST_ONE_UNDERSCORE = '_field_for_request'
27 | __STRING_FORMAT_FIELD_FOR_REQUEST_TWO_UNDERSCORE = '__field_for_request'
28 |
29 | def is_all_field_none(self) -> None:
30 | raise NotImplementedError
31 |
32 | def to_json(self) -> str:
33 | return converter.class_to_json(self)
34 |
35 | @classmethod
36 | def _from_json_array_nested(cls, response_raw: BunqResponseRaw) -> BunqResponse[BunqModel]:
37 | json = response_raw.body_bytes.decode()
38 | obj = converter.json_to_class(dict, json)
39 | value = converter.deserialize(cls, obj[cls._FIELD_RESPONSE])
40 |
41 | return BunqResponse(value, response_raw.headers)
42 |
43 | @classmethod
44 | def _from_json(cls,
45 | response_raw: BunqResponseRaw,
46 | wrapper: str = None) -> BunqResponse[BunqModel]:
47 | json = response_raw.body_bytes.decode()
48 | obj = converter.json_to_class(dict, json)
49 | value = converter.deserialize(
50 | cls,
51 | cls._unwrap_response_single(obj, wrapper)
52 | )
53 |
54 | return BunqResponse(value, response_raw.headers)
55 |
56 | @classmethod
57 | def _unwrap_response_single(cls, obj: Dict, wrapper: str = None) -> Dict:
58 | if wrapper is None:
59 | return obj[cls._FIELD_RESPONSE][cls._INDEX_FIRST]
60 |
61 | response_obj = obj[cls._FIELD_RESPONSE][cls._INDEX_FIRST]
62 |
63 | if wrapper in response_obj:
64 | return response_obj[wrapper]
65 |
66 | for key in response_obj.keys():
67 | if key.startswith(wrapper):
68 | return response_obj[key]
69 |
70 | raise KeyError(f"Could not find '{wrapper}' or any subclass in response: {list(response_obj.keys())}")
71 |
72 | @classmethod
73 | def _process_for_id(cls, response_raw: BunqResponseRaw) -> BunqResponse[int]:
74 | from bunq.sdk.model.core.id import Id
75 |
76 | json = response_raw.body_bytes.decode()
77 | obj = converter.json_to_class(dict, json)
78 | id_ = converter.deserialize(
79 | Id,
80 | cls._unwrap_response_single(obj, cls._FIELD_ID)
81 | )
82 |
83 | return BunqResponse(id_.id_, response_raw.headers)
84 |
85 | @classmethod
86 | def _process_for_uuid(cls, response_raw: BunqResponseRaw) -> BunqResponse[str]:
87 | from bunq.sdk.model.core.uuid import Uuid
88 |
89 | json = response_raw.body_bytes.decode()
90 | obj = converter.json_to_class(dict, json)
91 | uuid = converter.deserialize(
92 | Uuid,
93 | cls._unwrap_response_single(obj, cls._FIELD_UUID)
94 | )
95 |
96 | return BunqResponse(uuid.uuid, response_raw.headers)
97 |
98 | @classmethod
99 | def _from_json_list(cls,
100 | response_raw: BunqResponseRaw,
101 | wrapper: str = None) -> BunqResponse[List[T]]:
102 | from bunq import Pagination
103 |
104 | json = response_raw.body_bytes.decode()
105 | obj = converter.json_to_class(dict, json)
106 | array = obj[cls._FIELD_RESPONSE]
107 | array_deserialized = []
108 |
109 | for item in array:
110 | item_unwrapped = item if wrapper is None else item[wrapper]
111 | item_deserialized = converter.deserialize(cls, item_unwrapped)
112 | array_deserialized.append(item_deserialized)
113 |
114 | pagination = None
115 |
116 | if cls._FIELD_PAGINATION in obj:
117 | pagination = converter.deserialize(Pagination, obj[cls._FIELD_PAGINATION])
118 |
119 | return BunqResponse(array_deserialized, response_raw.headers, pagination)
120 |
121 | @classmethod
122 | def _get_api_context(cls) -> ApiContext:
123 | from bunq.sdk.context.bunq_context import BunqContext
124 |
125 | return BunqContext.api_context()
126 |
127 | @classmethod
128 | def _determine_user_id(cls) -> int:
129 | from bunq.sdk.context.bunq_context import BunqContext
130 |
131 | return BunqContext.user_context().user_id
132 |
133 | @classmethod
134 | def _determine_monetary_account_id(cls, monetary_account_id: int = None) -> int:
135 | from bunq.sdk.context.bunq_context import BunqContext
136 |
137 | if monetary_account_id is None:
138 | return BunqContext.user_context().primary_monetary_account.id_
139 |
140 | return monetary_account_id
141 |
142 | @classmethod
143 | def _remove_field_for_request(cls, json_str: str) -> str:
144 | return json_str.replace(
145 | cls.__STRING_FORMAT_FIELD_FOR_REQUEST_TWO_UNDERSCORE,
146 | cls.__STRING_FORMAT_EMPTY
147 | ).replace(
148 | cls.__STRING_FORMAT_FIELD_FOR_REQUEST_ONE_UNDERSCORE,
149 | cls.__STRING_FORMAT_EMPTY
150 | )
151 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/RequestInquiry.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "REQUEST",
5 | "event_type": "REQUEST_INQUIRY_ACCEPTED",
6 | "object": {
7 | "RequestInquiry": {
8 | "id": 21,
9 | "created": "2017-07-16 01:58:10.359035",
10 | "updated": "2017-07-22 23:37:35.925263",
11 | "time_responded": "2017-07-22 23:37:35.889673",
12 | "time_expiry": null,
13 | "monetary_account_id": 43,
14 | "amount_inquired": {
15 | "currency": "EUR",
16 | "value": "8.77"
17 | },
18 | "amount_responded": null,
19 | "status": "REVOKED",
20 | "description": "Test request inquiry to NL60BUNQ2025103972",
21 | "merchant_reference": null,
22 | "user_alias_created": {
23 | "iban": "NL32BUNQ2025103506",
24 | "is_light": false,
25 | "display_name": "N. Garland",
26 | "avatar": {
27 | "uuid": "e47f5214-2198-4605-886c-9950714476f3",
28 | "image": [
29 | {
30 | "attachment_public_uuid": "3c7d4d11-d203-4ed4-b471-5b5e24079bf6",
31 | "height": 1024,
32 | "width": 1024,
33 | "content_type": "image\/png"
34 | }
35 | ],
36 | "anchor_uuid": null
37 | },
38 | "label_user": {
39 | "uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da",
40 | "display_name": "N. Garland",
41 | "country": "NL",
42 | "avatar": {
43 | "uuid": "ad92c001-a4ec-4344-89ad-bf6a6b0e5628",
44 | "image": [
45 | {
46 | "attachment_public_uuid": "2b7bc30e-d6af-4440-90ba-a801cba3d8d7",
47 | "height": 480,
48 | "width": 480,
49 | "content_type": "image\/jpeg"
50 | }
51 | ],
52 | "anchor_uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da"
53 | },
54 | "public_nick_name": "Niels (nickname)"
55 | },
56 | "country": "NL"
57 | },
58 | "user_alias_revoked": {
59 | "uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da",
60 | "display_name": "N. Garland",
61 | "country": "NL",
62 | "avatar": {
63 | "uuid": "ad92c001-a4ec-4344-89ad-bf6a6b0e5628",
64 | "image": [
65 | {
66 | "attachment_public_uuid": "2b7bc30e-d6af-4440-90ba-a801cba3d8d7",
67 | "height": 480,
68 | "width": 480,
69 | "content_type": "image\/jpeg"
70 | }
71 | ],
72 | "anchor_uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da"
73 | },
74 | "public_nick_name": "Niels (nickname)"
75 | },
76 | "counterparty_alias": {
77 | "iban": "NL60BUNQ2025103972",
78 | "is_light": false,
79 | "display_name": "Charlie CH Arl (nickname)",
80 | "avatar": {
81 | "uuid": "5188b2d7-5cf9-49ca-ba47-625fee317e79",
82 | "image": [
83 | {
84 | "attachment_public_uuid": "f427ccd8-81e7-46a7-a9bc-9c52e66f15ae",
85 | "height": 1024,
86 | "width": 1024,
87 | "content_type": "image\/png"
88 | }
89 | ],
90 | "anchor_uuid": null
91 | },
92 | "label_user": {
93 | "uuid": "3f4973b4-7503-4317-bf4c-bc63e4f6a858",
94 | "display_name": "Charlie CH Arl (nickname)",
95 | "country": "NL",
96 | "avatar": {
97 | "uuid": "cf4eedd8-afea-4374-9505-7ea5af898091",
98 | "image": [
99 | {
100 | "attachment_public_uuid": "e6f69b18-0aba-4001-98d6-968d464b706c",
101 | "height": 480,
102 | "width": 480,
103 | "content_type": "image\/jpeg"
104 | }
105 | ],
106 | "anchor_uuid": "3f4973b4-7503-4317-bf4c-bc63e4f6a858"
107 | },
108 | "public_nick_name": "Charlie CH Arl (nickname)"
109 | },
110 | "country": "NL"
111 | },
112 | "attachment": [],
113 | "minimum_age": null,
114 | "require_address": null,
115 | "geolocation": null,
116 | "type": "INTERNAL",
117 | "sub_type": "NONE",
118 | "bunqme_share_url": null,
119 | "batch_id": null,
120 | "scheduled_id": null,
121 | "address_billing": null,
122 | "address_shipping": null,
123 | "conversation": null,
124 | "allow_chat": true
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/ScheduledPayment.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "SCHEDULE_RESULT",
5 | "event_type": "SCHEDULE_DEFINITION_PAYMENT_BATCH_CANCELLED",
6 | "object": {
7 | "ScheduledPayment": {
8 | "id": 2,
9 | "created": "2017-07-11 22:56:20.737255",
10 | "updated": "2017-07-11 22:56:20.737255",
11 | "monetary_account_id": 44,
12 | "schedule": {
13 | "time_start": "2017-07-25 22:56:20.571271",
14 | "time_next": "2018-07-25 22:56:20.000000",
15 | "time_end": "2018-07-25 22:56:20.571271",
16 | "recurrence_unit": "YEARLY",
17 | "recurrence_size": 1
18 | },
19 | "status": "ACTIVE",
20 | "label_schedule_user_created": {
21 | "uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f",
22 | "display_name": "J. Barrett",
23 | "country": "NL",
24 | "avatar": {
25 | "uuid": "3872244f-82c5-4131-82d9-ba878f7e4cb6",
26 | "image": [
27 | {
28 | "attachment_public_uuid": "abca51a8-af0f-4ab5-b329-645809781615",
29 | "height": 480,
30 | "width": 480,
31 | "content_type": "image\/jpeg"
32 | }
33 | ],
34 | "anchor_uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f"
35 | },
36 | "public_nick_name": "Jodi (nickname)"
37 | },
38 | "label_schedule_user_canceled": null,
39 | "payment": {
40 | "amount": {
41 | "currency": "EUR",
42 | "value": "-8.89"
43 | },
44 | "alias": {
45 | "iban": "NL71BUNQ2025104162",
46 | "is_light": false,
47 | "display_name": "J. Barrett",
48 | "avatar": {
49 | "uuid": "8418f60a-2f89-443e-94ab-5f4cc10a1a1f",
50 | "image": [
51 | {
52 | "attachment_public_uuid": "c00867c4-8bbe-4dd7-ac72-85798a2969cb",
53 | "height": 1024,
54 | "width": 1024,
55 | "content_type": "image\/png"
56 | }
57 | ],
58 | "anchor_uuid": null
59 | },
60 | "label_user": {
61 | "uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f",
62 | "display_name": "J. Barrett",
63 | "country": "NL",
64 | "avatar": {
65 | "uuid": "3872244f-82c5-4131-82d9-ba878f7e4cb6",
66 | "image": [
67 | {
68 | "attachment_public_uuid": "abca51a8-af0f-4ab5-b329-645809781615",
69 | "height": 480,
70 | "width": 480,
71 | "content_type": "image\/jpeg"
72 | }
73 | ],
74 | "anchor_uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f"
75 | },
76 | "public_nick_name": "Jodi (nickname)"
77 | },
78 | "country": "NL"
79 | },
80 | "counterparty_alias": {
81 | "iban": "NL59BUNQ2025104669",
82 | "is_light": false,
83 | "display_name": "Alpha Corp.",
84 | "avatar": {
85 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a",
86 | "image": [
87 | {
88 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439",
89 | "height": 1024,
90 | "width|": 1024,
91 | "content_type": "image\/png"
92 | }
93 | ],
94 | "anchor_uuid": null
95 | },
96 | "label_user": {
97 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3",
98 | "display_name": "Alpha Corp.",
99 | "country": "NL",
100 | "avatar": {
101 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9",
102 | "image": [
103 | {
104 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a",
105 | "height": 640,
106 | "width": 640,
107 | "content_type": "image\/png"
108 | }
109 | ],
110 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3"
111 | },
112 | "public_nick_name": "Alpha Corp."
113 | },
114 | "country": "NL"
115 | },
116 | "description": "Test payment to NL59BUNQ2025104669",
117 | "merchant_reference": null,
118 | "type": "BUNQ",
119 | "sub_type": "PAYMENT"
120 | }
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/tests/assets/NotificationUrlJsons/MasterCardAction.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotificationUrl": {
3 | "target_url": "nope",
4 | "category": "DRAFT_PAYMENT",
5 | "event_type": "DRAFT_PAYMENT_ACCEPTED",
6 | "object": {
7 | "MasterCardAction": {
8 | "id": 1,
9 | "created": "2017-07-10 06:33:10.497530",
10 | "updated": "2017-07-10 06:33:10.676651",
11 | "monetary_account_id": 31,
12 | "card_id": 2,
13 | "amount_local": {
14 | "currency": "EUR",
15 | "value": "2.00"
16 | },
17 | "amount_billing": {
18 | "currency": "EUR",
19 | "value": "2.00"
20 | },
21 | "amount_original_local": {
22 | "currency": "EUR",
23 | "value": "2.00"
24 | },
25 | "amount_original_billing": {
26 | "currency": "EUR",
27 | "value": "2.00"
28 | },
29 | "amount_fee": {
30 | "currency": "EUR",
31 | "value": "0.00"
32 | },
33 | "decision": "COUNTRY_NOT_PERMITTED",
34 | "decision_description": "Card transaction was denied, because the country was not permitted in card settings.",
35 | "decision_description_translated": "Card transaction was denied, because the country was not permitted in card settings.",
36 | "description": "Spar city Sloterdijk Amsterdam, NL",
37 | "authorisation_status": "BLOCKED",
38 | "authorisation_type": "NORMAL_AUTHORISATION",
39 | "city": "Amsterdam",
40 | "alias": {
41 | "iban": "NL88BUNQ2025103565",
42 | "is_light": false,
43 | "display_name": "B.O. Varb",
44 | "avatar": {
45 | "uuid": "0c245e21-e3bc-4bab-8880-7ca6d09691b5",
46 | "image": [
47 | {
48 | "attachment_public_uuid": "1441cea4-3f24-43d4-9f35-2dfd8ff7bc8c",
49 | "height": 1024,
50 | "width": 1024,
51 | "content_type": "image\/png"
52 | }
53 | ],
54 | "anchor_uuid": null
55 | },
56 | "label_user": {
57 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80",
58 | "display_name": "B.O. Varb",
59 | "country": "NL",
60 | "avatar": {
61 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881",
62 | "image": [
63 | {
64 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89",
65 | "height": 480,
66 | "width": 480,
67 | "content_type": "image\/jpeg"
68 | }
69 | ],
70 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80"
71 | },
72 | "public_nick_name": "Bravo O (nickname)"
73 | },
74 | "country": "NL"
75 | },
76 | "counterparty_alias": {
77 | "iban": null,
78 | "is_light": null,
79 | "display_name": "Spar city Sloterdijk",
80 | "avatar": {
81 | "uuid": "ed91da5e-6100-42ab-a5b1-bcbeab62cb96",
82 | "image": [
83 | {
84 | "attachment_public_uuid": "58b3df6c-bfa4-4a42-91b3-db0e7f2ac64e",
85 | "height": 640,
86 | "width": 640,
87 | "content_type": "image\/jpeg"
88 | }
89 | ],
90 | "anchor_uuid": null
91 | },
92 | "label_user": {
93 | "uuid": null,
94 | "display_name": "Spar city Sloterdijk",
95 | "country": "NL",
96 | "avatar": null,
97 | "public_nick_name": "Spar city Sloterdijk"
98 | },
99 | "country": "NL",
100 | "merchant_category_code": "5411"
101 | },
102 | "label_card": {
103 | "uuid": "c3b99cac-3677-4311-841c-4a7bba7397f2",
104 | "type": "MAESTRO",
105 | "second_line": "Pets",
106 | "expiry_date": "2021-07-31",
107 | "status": "ACTIVE",
108 | "label_user": {
109 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80",
110 | "display_name": "B.O. Varb",
111 | "country": "NL",
112 | "avatar": {
113 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881",
114 | "image": [
115 | {
116 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89",
117 | "height": 480,
118 | "width": 480,
119 | "content_type": "image\/jpeg"
120 | }
121 | ],
122 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80"
123 | },
124 | "public_nick_name": "Bravo O (nickname)"
125 | }
126 | },
127 | "token_status": null,
128 | "reservation_expiry_time": null,
129 | "applied_limit": null,
130 | "conversation": null,
131 | "allow_chat": true,
132 | "pan_entry_mode_user": "ICC",
133 | "eligible_whitelist_id": null
134 | }
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/tests/model/generated/object/test_notification_url.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from typing import Optional
4 |
5 | from bunq.sdk.model.core.bunq_model import BunqModel
6 | from bunq.sdk.model.generated import endpoint
7 | from bunq.sdk.model.generated import object_
8 | from bunq.sdk.model.generated.endpoint import PaymentApiObject, BunqMeTabApiObject, DraftPaymentApiObject, MasterCardActionApiObject, MonetaryAccountApiObject, \
9 | MonetaryAccountBankApiObject, PaymentBatchApiObject, RequestInquiryApiObject, RequestResponseApiObject, SchedulePaymentApiObject, ScheduleInstanceApiObject, \
10 | ShareInviteMonetaryAccountInquiryApiObject, ShareInviteMonetaryAccountResponseApiObject
11 | from bunq.sdk.model.generated.object_ import NotificationUrlObject
12 | from tests import bunq_test
13 |
14 |
15 | class TestNotificationUrl(bunq_test.BunqSdkTestCase):
16 | # Getter string constants
17 | _GETTER_PAYMENT = 'Payment'
18 | _GETTER_BUNQ_ME_TAB = 'BunqMeTab'
19 | _GETTER_CHAT_MESSAGE_ANNOUNCEMENT = 'ChatMessageAnnouncement'
20 | _GETTER_CHAT_MESSAGE = 'ChatMessage'
21 | _GETTER_DRAFT_PAYMENT = 'DraftPayment'
22 | _GETTER_MASTER_CARD_ACTION = 'MasterCardAction'
23 | _GETTER_MONETARY_ACCOUNT_BANK = 'MonetaryAccountBank'
24 | _GETTER_MONETARY_ACCOUNT = 'MonetaryAccount'
25 | _GETTER_PAYMENT_BATCH = 'PaymentBatch'
26 | _GETTER_REQUEST_INQUIRY = 'RequestInquiry'
27 | _GETTER_REQUEST_RESPONSE = 'RequestResponse'
28 | _GETTER_SCHEDULE_PAYMENT = 'ScheduledPayment'
29 | _GETTER_SCHEDULE_INSTANCE = 'ScheduledInstance'
30 | _GETTER_SHARE_INVITE_BANK_INQUIRY = 'ShareInviteBankInquiry'
31 | _GETTER_SHARE_INVITE_BANK_RESPONSE = 'ShareInviteBankResponse'
32 |
33 | # Model json paths constants.
34 | BASE_PATH_JSON_MODEL = '../../../assets/NotificationUrlJsons'
35 | JSON_PATH_MUTATION_MODEL = BASE_PATH_JSON_MODEL + '/Mutation.json'
36 | JSON_PATH_BUNQ_ME_TAB_MODEL = BASE_PATH_JSON_MODEL + '/BunqMeTab.json'
37 | JSON_PATH_CHAT_MESSAGE_ANNOUNCEMENT_MODEL = BASE_PATH_JSON_MODEL + '/ChatMessageAnnouncement.json'
38 | JSON_PATH_DRAFT_PAYMENT_MODEL = BASE_PATH_JSON_MODEL + '/DraftPayment.json'
39 | JSON_PATH_MASTER_CARD_ACTION_MODEL = BASE_PATH_JSON_MODEL + '/MasterCardAction.json'
40 | JSON_PATH_MONETARY_ACCOUNT_BANK_MODEL = BASE_PATH_JSON_MODEL + '/MonetaryAccountBank.json'
41 | JSON_PATH_PAYMENT_BATCH_MODEL = BASE_PATH_JSON_MODEL + '/PaymentBatch.json'
42 | JSON_PATH_REQUEST_INQUIRY_MODEL = BASE_PATH_JSON_MODEL + '/RequestInquiry.json'
43 | JSON_PATH_REQUEST_RESPONSE_MODEL = BASE_PATH_JSON_MODEL + '/RequestResponse.json'
44 | JSON_PATH_SCHEDULE_PAYMENT_MODEL = BASE_PATH_JSON_MODEL + '/ScheduledPayment.json'
45 | JSON_PATH_SCHEDULE_INSTANCE_MODEL = BASE_PATH_JSON_MODEL + '/ScheduledInstance.json'
46 | JSON_PATH_SHARE_INVITE_BANK_INQUIRY_MODEL = BASE_PATH_JSON_MODEL + '/ShareInviteBankInquiry.json'
47 | JSON_PATH_SHARE_INVITE_BANK_RESPONSE_MODEL = BASE_PATH_JSON_MODEL + '/ShareInviteBankResponse.json'
48 |
49 | # Model root key.
50 | _KEY_NOTIFICATION_URL_MODEL = 'NotificationUrl'
51 |
52 | # Model modules constants.
53 | _MODEL_MODULES = [
54 | object_,
55 | endpoint,
56 | ]
57 |
58 | @classmethod
59 | def setUpClass(cls):
60 | pass
61 |
62 | def setUp(self):
63 | pass
64 |
65 | # File mode constants.
66 | _FILE_MODE_READ = 'r'
67 |
68 | def execute_notification_url_test(self,
69 | file_path: str,
70 | class_name: str,
71 | getter_name: str,
72 | sub_class_expected_object_name: str = None,
73 | sub_class_getter_name: str = None) -> None:
74 | notification_url = self.get_notification_url(file_path)
75 | self.assertIsNotNone(notification_url)
76 | self.assertIsNotNone(notification_url.object_)
77 |
78 | expected_model = getattr(notification_url.object_, getter_name)
79 | referenced_model = notification_url.object_.get_referenced_object()
80 |
81 | self.assertIsNotNone(expected_model)
82 | self.assertIsNotNone(referenced_model)
83 | self.assertTrue(self.is_model_reference(referenced_model, class_name))
84 |
85 | if sub_class_expected_object_name is not None:
86 | sub_class_model = getattr(referenced_model, sub_class_getter_name)
87 |
88 | self.assertIsNotNone(sub_class_model)
89 | self.assertTrue(isinstance(sub_class_model, self.get_model_type_or_none(sub_class_expected_object_name)))
90 |
91 | @classmethod
92 | def is_model_reference(cls, referenced_model: BunqModel, class_name: str) -> bool:
93 | model_type = cls.get_model_type_or_none(class_name)
94 |
95 | if model_type is None:
96 | return False
97 |
98 | return isinstance(referenced_model, model_type)
99 |
100 | @classmethod
101 | def get_model_type_or_none(cls, class_name: str) -> Optional[type]:
102 | for module_ in cls._MODEL_MODULES:
103 | if hasattr(module_, class_name):
104 | return getattr(module_, class_name)
105 |
106 | return None
107 |
108 | def get_notification_url(self, file_path: str) -> NotificationUrlObject:
109 | base_path = os.path.dirname(__file__)
110 | file_path = os.path.abspath(os.path.join(base_path, file_path))
111 |
112 | with open(file_path, self._FILE_MODE_READ) as f:
113 | json_string = f.read()
114 | json_object = json.loads(json_string)
115 | json_string = json.dumps(json_object[self._KEY_NOTIFICATION_URL_MODEL])
116 |
117 | self.assertTrue(self._KEY_NOTIFICATION_URL_MODEL in json_object)
118 |
119 | return NotificationUrlObject.from_json(json_string)
120 |
121 | def test_mutation_model(self):
122 | self.execute_notification_url_test(
123 | self.JSON_PATH_MUTATION_MODEL,
124 | PaymentApiObject.__name__,
125 | self._GETTER_PAYMENT
126 | )
127 |
128 | def test_bunq_me_tab_model(self):
129 | self.execute_notification_url_test(
130 | self.JSON_PATH_BUNQ_ME_TAB_MODEL,
131 | BunqMeTabApiObject.__name__,
132 | self._GETTER_BUNQ_ME_TAB
133 | )
134 |
135 | def test_draft_payment_model(self):
136 | self.execute_notification_url_test(
137 | self.JSON_PATH_DRAFT_PAYMENT_MODEL,
138 | DraftPaymentApiObject.__name__,
139 | self._GETTER_DRAFT_PAYMENT
140 | )
141 |
142 | def test_mastercard_action(self):
143 | self.execute_notification_url_test(
144 | self.JSON_PATH_MASTER_CARD_ACTION_MODEL,
145 | MasterCardActionApiObject.__name__,
146 | self._GETTER_MASTER_CARD_ACTION
147 | )
148 |
149 | def test_monetary_account_bank_model(self):
150 | self.execute_notification_url_test(
151 | self.JSON_PATH_MONETARY_ACCOUNT_BANK_MODEL,
152 | MonetaryAccountApiObject.__name__,
153 | self._GETTER_MONETARY_ACCOUNT,
154 | MonetaryAccountBankApiObject.__name__,
155 | self._GETTER_MONETARY_ACCOUNT_BANK
156 | )
157 |
158 | def test_payment_batch_model(self):
159 | self.execute_notification_url_test(
160 | self.JSON_PATH_PAYMENT_BATCH_MODEL,
161 | PaymentBatchApiObject.__name__,
162 | self._GETTER_PAYMENT_BATCH
163 | )
164 |
165 | def test_request_inquiry_model(self):
166 | self.execute_notification_url_test(
167 | self.JSON_PATH_REQUEST_INQUIRY_MODEL,
168 | RequestInquiryApiObject.__name__,
169 | self._GETTER_REQUEST_INQUIRY
170 | )
171 |
172 | def test_request_response_model(self):
173 | self.execute_notification_url_test(
174 | self.JSON_PATH_REQUEST_RESPONSE_MODEL,
175 | RequestResponseApiObject.__name__,
176 | self._GETTER_REQUEST_RESPONSE
177 | )
178 |
179 | def test_scheduled_payment_model(self):
180 | self.execute_notification_url_test(
181 | self.JSON_PATH_SCHEDULE_PAYMENT_MODEL,
182 | SchedulePaymentApiObject.__name__,
183 | self._GETTER_SCHEDULE_PAYMENT
184 | )
185 |
186 | def test_scheduled_instance_model(self):
187 | self.execute_notification_url_test(
188 | self.JSON_PATH_SCHEDULE_INSTANCE_MODEL,
189 | ScheduleInstanceApiObject.__name__,
190 | self._GETTER_SCHEDULE_INSTANCE
191 | )
192 |
193 | def test_share_invite_bank_inquiry(self):
194 | self.execute_notification_url_test(
195 | self.JSON_PATH_SHARE_INVITE_BANK_INQUIRY_MODEL,
196 | ShareInviteMonetaryAccountInquiryApiObject.__name__,
197 | self._GETTER_SHARE_INVITE_BANK_INQUIRY
198 | )
199 |
200 | def test_share_invite_bank_response(self):
201 | self.execute_notification_url_test(
202 | self.JSON_PATH_SHARE_INVITE_BANK_RESPONSE_MODEL,
203 | ShareInviteMonetaryAccountResponseApiObject.__name__,
204 | self._GETTER_SHARE_INVITE_BANK_RESPONSE
205 | )
206 |
--------------------------------------------------------------------------------
/bunq/sdk/security/security.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import base64
4 | import hmac
5 | import re
6 | import typing
7 | from base64 import b64encode
8 | from hashlib import sha1
9 | from typing import Dict, List
10 |
11 | from Cryptodome import Cipher
12 | from Cryptodome import Random
13 | from Cryptodome.Cipher import AES
14 | from Cryptodome.Cipher import PKCS1_v1_5 as PKCS1_v1_5_Cipher
15 | from Cryptodome.Hash import SHA256
16 | from Cryptodome.PublicKey import RSA
17 | from Cryptodome.PublicKey.RSA import RsaKey
18 | from Cryptodome.Signature import PKCS1_v1_5
19 | from requests.structures import CaseInsensitiveDict
20 |
21 | from bunq.sdk.exception.bunq_exception import BunqException
22 |
23 | if typing.TYPE_CHECKING:
24 | from bunq.sdk.context.api_context import ApiContext
25 |
26 | # Error constants.
27 | _ERROR_INVALID_SIGNATURE = 'Could not validate response signature.'
28 |
29 | # Size of private RSA key to generate
30 | _RSA_KEY_SIZE = 2048
31 |
32 | # Constants to perform re-encoding of the public key
33 | _PATTERN_RSA = r' RSA '
34 | _REPLACEMENT_RSA = ' '
35 |
36 | # Number of PKCS to use for exporting the private key
37 | _PKCS_NUMBER_PRIVATE_KEY = 8
38 |
39 | # Constants to generate request head string
40 | _FORMAT_METHOD_AND_ENDPOINT = '{} /v1/{}\n'
41 | _FORMAT_HEADER_STRING = '{}: {}\n'
42 | _DELIMITER_NEWLINE = '\n'
43 |
44 | # Constants for building header string to sign
45 | _PATTERN_HEADER_PREFIX_BUNQ = r'X-Bunq-'
46 | _HEADER_CACHE_CONTROL = 'Cache-Control'
47 | _HEADER_USER_AGENT = 'User-Agent'
48 |
49 | # Constants for AES encryption
50 | _AES_KEY_SIZE = 32
51 | _BLOCK_SIZE = 16
52 |
53 | # Regex constants
54 | _REGEX_FOR_LOWERCASE_HEADERS = r'(-[a-z])'
55 |
56 | # Encryption-specific headers
57 | _HEADER_CLIENT_ENCRYPTION_KEY = 'X-Bunq-Client-Encryption-Key'
58 | _HEADER_CLIENT_ENCRYPTION_IV = 'X-Bunq-Client-Encryption-Iv'
59 | _HEADER_CLIENT_ENCRYPTION_HMAC = 'X-Bunq-Client-Encryption-Hmac'
60 | _HEADER_SERVER_SIGNATURE = 'X-Bunq-Server-Signature'
61 |
62 |
63 | def generate_rsa_private_key() -> RsaKey:
64 | return RSA.generate(_RSA_KEY_SIZE)
65 |
66 |
67 | def public_key_to_string(public_key: RsaKey) -> str:
68 | return re.sub(
69 | _PATTERN_RSA,
70 | _REPLACEMENT_RSA,
71 | public_key.exportKey().decode()
72 | )
73 |
74 |
75 | def private_key_to_string(private_key: RsaKey) -> str:
76 | return private_key.exportKey(pkcs=_PKCS_NUMBER_PRIVATE_KEY).decode()
77 |
78 |
79 | def rsa_key_from_string(string: str) -> RsaKey:
80 | return RSA.import_key(string)
81 |
82 |
83 | def sign_request(private_key: RsaKey,
84 | body_bytes: bytes) -> str:
85 | signer = PKCS1_v1_5.new(private_key)
86 | digest = SHA256.new()
87 | digest.update(body_bytes)
88 | sign = signer.sign(digest)
89 |
90 | return b64encode(sign)
91 |
92 |
93 | def _generate_request_head_bytes(method: str,
94 | endpoint: str,
95 | headers: Dict[str, str]) -> bytes:
96 | head_string = _FORMAT_METHOD_AND_ENDPOINT.format(method, endpoint)
97 | header_tuples = sorted((k, headers[k]) for k in headers)
98 |
99 | for name, value in header_tuples:
100 | if _should_sign_request_header(name):
101 | head_string += _FORMAT_HEADER_STRING.format(name, value)
102 |
103 | return (head_string + _DELIMITER_NEWLINE).encode()
104 |
105 |
106 | def _should_sign_request_header(header_name: str) -> bool:
107 | if header_name in {_HEADER_USER_AGENT, _HEADER_CACHE_CONTROL}:
108 | return True
109 |
110 | if re.match(_PATTERN_HEADER_PREFIX_BUNQ, header_name):
111 | return True
112 |
113 | return False
114 |
115 |
116 | def generate_signature(string_to_sign: str, key_pair: RsaKey) -> str:
117 | bytes_to_sign = string_to_sign.encode()
118 | signer = PKCS1_v1_5.new(key_pair)
119 | digest = SHA256.new()
120 | digest.update(bytes_to_sign)
121 | sign = signer.sign(digest)
122 |
123 | return b64encode(sign)
124 |
125 |
126 | def encrypt(api_context: ApiContext,
127 | request_bytes: bytes,
128 | custom_headers: Dict[str, str]) -> bytes:
129 | key = Random.get_random_bytes(_AES_KEY_SIZE)
130 | iv = Random.get_random_bytes(_BLOCK_SIZE)
131 | _add_header_client_encryption_key(api_context, key, custom_headers)
132 | _add_header_client_encryption_iv(iv, custom_headers)
133 | request_bytes = _encrypt_request_bytes(request_bytes, key, iv)
134 | _add_header_client_encryption_hmac(request_bytes, key, iv, custom_headers)
135 |
136 | return request_bytes
137 |
138 |
139 | def _add_header_client_encryption_key(api_context: ApiContext,
140 | key: bytes,
141 | custom_headers: Dict[str, str]) -> None:
142 | public_key_server = api_context.installation_context.public_key_server
143 | key_cipher = PKCS1_v1_5_Cipher.new(public_key_server)
144 | key_encrypted = key_cipher.encrypt(key)
145 | key_encrypted_base64 = base64.b64encode(key_encrypted).decode()
146 | custom_headers[_HEADER_CLIENT_ENCRYPTION_KEY] = key_encrypted_base64
147 |
148 |
149 | def _add_header_client_encryption_iv(iv: bytes,
150 | custom_headers: Dict[str, str]) -> None:
151 | custom_headers[_HEADER_CLIENT_ENCRYPTION_IV] = base64.b64encode(iv).decode()
152 |
153 |
154 | def _encrypt_request_bytes(request_bytes: bytes,
155 | key: bytes,
156 | iv: bytes) -> bytes:
157 | cipher = Cipher.AES.new(key, Cipher.AES.MODE_CBC, iv)
158 | request_bytes_padded = _pad_bytes(request_bytes)
159 |
160 | return cipher.encrypt(request_bytes_padded)
161 |
162 |
163 | def _pad_bytes(request_bytes: bytes) -> bytes:
164 | padding_length = (_BLOCK_SIZE - len(request_bytes) % _BLOCK_SIZE)
165 | padding_character = bytes(bytearray([padding_length]))
166 |
167 | return request_bytes + padding_character * padding_length
168 |
169 |
170 | def _add_header_client_encryption_hmac(request_bytes: bytes,
171 | key: bytes,
172 | iv: bytes,
173 | custom_headers: Dict[str, str]) -> None:
174 | hashed = hmac.new(key, iv + request_bytes, sha1)
175 | hashed_base64 = base64.b64encode(hashed.digest()).decode()
176 | custom_headers[_HEADER_CLIENT_ENCRYPTION_HMAC] = hashed_base64
177 |
178 |
179 | def validate_response(public_key_server: RsaKey,
180 | status_code: int,
181 | body_bytes: bytes,
182 | headers: CaseInsensitiveDict[str, str]) -> None:
183 | if is_valid_response_header_with_body(public_key_server, status_code, body_bytes, headers):
184 | return
185 | elif is_valid_response_body(public_key_server, body_bytes, headers):
186 | return
187 | else:
188 | raise BunqException(_ERROR_INVALID_SIGNATURE)
189 |
190 |
191 | def is_valid_response_header_with_body(public_key_server: RsaKey,
192 | status_code: int,
193 | body_bytes: bytes,
194 | headers: CaseInsensitiveDict[str, str]) -> bool:
195 | head_bytes = _generate_response_head_bytes(status_code, headers)
196 | bytes_signed = head_bytes + body_bytes
197 | signer = PKCS1_v1_5.pkcs1_15.new(public_key_server)
198 | digest = SHA256.new()
199 | digest.update(bytes_signed)
200 |
201 | try:
202 | signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE]))
203 |
204 | return True
205 | except ValueError:
206 | return False
207 |
208 |
209 | def is_valid_response_body(public_key_server: RsaKey,
210 | body_bytes: bytes,
211 | headers: CaseInsensitiveDict[str, str]) -> bool:
212 | signer = PKCS1_v1_5.pkcs1_15.new(public_key_server)
213 | digest = SHA256.new()
214 | digest.update(body_bytes)
215 |
216 | try:
217 | signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE]))
218 |
219 | return True
220 | except ValueError:
221 | return False
222 |
223 |
224 | def _generate_response_head_bytes(status_code: int,
225 | headers: CaseInsensitiveDict[str, str]) -> bytes:
226 | head_string = str(status_code) + _DELIMITER_NEWLINE
227 | header_tuples = sorted((k, headers[k]) for k in headers)
228 |
229 | for name, value in header_tuples:
230 | name = _get_header_correctly_cased(name)
231 |
232 | if _should_sign_response_header(name):
233 | head_string += _FORMAT_HEADER_STRING.format(name, value)
234 |
235 | return (head_string + _DELIMITER_NEWLINE).encode()
236 |
237 |
238 | def _get_header_correctly_cased(header_name: str) -> str:
239 | header_name = header_name.capitalize()
240 |
241 | matches = re.findall(_REGEX_FOR_LOWERCASE_HEADERS, header_name)
242 |
243 | for match in matches:
244 | header_name = (re.sub(match, match.upper(), header_name))
245 |
246 | return header_name
247 |
248 |
249 | def _should_sign_response_header(header_name: str) -> bool:
250 | if header_name == _HEADER_SERVER_SIGNATURE:
251 | return False
252 |
253 | if re.match(_PATTERN_HEADER_PREFIX_BUNQ, header_name):
254 | return True
255 |
256 | return False
257 |
258 |
259 | def get_certificate_chain_string(all_chain_certificate: List[str]):
260 | return _DELIMITER_NEWLINE.join(all_chain_certificate)
261 |
--------------------------------------------------------------------------------