diff --git a/poetry.lock b/poetry.lock index 334193d..3e9f102 100644 --- a/poetry.lock +++ b/poetry.lock @@ -682,7 +682,6 @@ category = "dev" optional = false python-versions = "*" files = [ - {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"}, {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, ] @@ -1195,6 +1194,26 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.20.3" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, +] + +[package.dependencies] +pytest = ">=6.1.0" +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "pytz" version = "2022.7.1" @@ -1376,7 +1395,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_12_6_arm64.whl", hash = "sha256:721bc4ba4525f53f6a611ec0967bdcee61b31df5a56801281027a3a6d1c2daf5"}, - {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_12_0_arm64.whl", hash = "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3"}, @@ -1912,4 +1930,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "0c3767854f6f81ddff7b1e703241c3b91efc5be67a5ad15673376ef4ecbe45d5" +content-hash = "056611c7a2e9cdd6465964c9f69d04512d4978762a5519c89e2758d5fe9871da" diff --git a/pyproject.toml b/pyproject.toml index bfdd7a9..95eeae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ typeguard = ">=2.13.3" xdoctest = {extras = ["colors"], version = ">=0.15.10"} myst-parser = {version = ">=0.16.1"} respx = "^0.20.1" +pytest-asyncio = "^0.20.3" [tool.coverage.paths] source = ["src", "*/site-packages"] diff --git a/src/checkedid/client.py b/src/checkedid/client.py index 984e736..f722aeb 100644 --- a/src/checkedid/client.py +++ b/src/checkedid/client.py @@ -1,4 +1,5 @@ from json import JSONDecodeError +from types import TracebackType from typing import Dict from typing import List from typing import Optional @@ -8,28 +9,31 @@ import httpx from httpx import Request from httpx import Response +from httpx._types import URLTypes +from . import endpoints +from . import errors from . import models -from .errors import CheckedIDAuthenticationError -from .errors import CheckedIDError -from .errors import CheckedIDNotFoundError -from .errors import CheckedIDValidationError _T = TypeVar("_T") -class Client: - ERROR_RESPONSE_MAPPING: Dict[int, Type[CheckedIDError]] = { - 422: CheckedIDValidationError, - 403: CheckedIDAuthenticationError, - 404: CheckedIDNotFoundError, +class BaseClient: + ERROR_RESPONSE_MAPPING: Dict[int, Type[errors.CheckedIDError]] = { + 422: errors.CheckedIDValidationError, + 403: errors.CheckedIDAuthenticationError, + 404: errors.CheckedIDNotFoundError, } def __init__(self, customer_code: str, base_url: str = "https://api.checkedid.eu/"): - self.httpx = httpx.Client(base_url=base_url, auth=self.authenticate_request) + self.base_url = base_url self.access_token: Optional[str] = None self.customer_code = customer_code + self.create_client(base_url) + + def create_client(self, base_url: URLTypes) -> None: + raise NotImplementedError def authenticate_request(self, request: Request) -> Request: if self.access_token: @@ -46,10 +50,38 @@ def process_response( return None + def handle_error_response(self, response: Response) -> None: + if response.status_code == 400: + raise errors.CheckedIDValidationError( + response.text, status_code=response.status_code + ) + + try: + json = response.json() + except JSONDecodeError: + json = {"message": response.text} + + json["status_code"] = response.status_code + + exception_type = self.map_exception(response) + raise exception_type( + status_code=response.status_code, json=json, message="Error from server" + ) + + def map_exception(self, response: Response) -> Type[errors.CheckedIDError]: + exception_type = self.ERROR_RESPONSE_MAPPING.get( + response.status_code, errors.CheckedIDError + ) + return exception_type + + +class Client(BaseClient): + client: httpx.Client + def oauth_token( self, grant_type: str, username: str, password: str ) -> Optional[models.OAuthToken]: - response = self.httpx.post( + response = self.client.post( "/oauth/token", data={"grant_type": grant_type, "username": username, "password": password}, ) @@ -62,8 +94,14 @@ def oauth_token( return typed_response return None + def __init__(self, customer_code: str, base_url: str = "https://api.checkedid.eu/"): + super().__init__(customer_code, base_url) + + def create_client(self, base_url: URLTypes) -> None: + self.client = httpx.Client(base_url=base_url, auth=self.authenticate_request) + def invitation_status(self, invitation_code: str) -> Optional[models.Invitation]: - response: Response = self.httpx.get( + response: Response = self.client.get( f"/result/status/{invitation_code}", headers={"Accept": "application/json"}, ) @@ -77,7 +115,7 @@ def invitations_create( CustomerCode=self.customer_code, Invitations=invitations ) - response: Response = self.httpx.post( + response: Response = self.client.post( "/invitations", json=obj.dict(), headers={"Accept": "application/json", "Content-Type": "application/json"}, @@ -86,7 +124,7 @@ def invitations_create( return self.process_response(response, models.CustomerDetails) def invitation_delete(self, invitation_code: str) -> bool: - response: Response = self.httpx.delete( + response: Response = self.client.delete( f"/invitation/{self.customer_code}/{invitation_code}", headers={"Accept": "application/json"}, ) @@ -99,37 +137,69 @@ def invitation_delete(self, invitation_code: str) -> bool: return False def dossier(self, dossier_number: str) -> Optional[models.ReportResponse]: - response = self.httpx.get(f"/report/{dossier_number}") + response = self.client.get(f"/report/{dossier_number}") return self.process_response(response, models.ReportResponse) def dossier_with_scope( self, dossier_number: str, scope: str ) -> Optional[models.ReportDataV3]: - response = self.httpx.get(f"/reportdata/{dossier_number}/{scope}") + response = self.client.get(f"/reportdata/{dossier_number}/{scope}") return self.process_response(response, models.ReportDataV3) - def handle_error_response(self, response: Response) -> None: - if response.status_code == 400: - raise CheckedIDValidationError( - response.text, status_code=response.status_code - ) - try: - json = response.json() - except JSONDecodeError: - json = {"message": response.text} +class ClientAsync(BaseClient): + """for asyncio""" - json["status_code"] = response.status_code + client: httpx.AsyncClient - exception_type = self.map_exception(response) - raise exception_type( - status_code=response.status_code, json=json, message="Error from server" + def __init__(self, customer_code: str, base_url: str = "https://api.checkedid.eu/"): + super().__init__(customer_code, base_url) + + async def oauth_token( + self, grant_type: str, username: str, password: str + ) -> Optional[models.OAuthToken]: + response = await self.client.post( + "/oauth/token", + data={"grant_type": grant_type, "username": username, "password": password}, ) - def map_exception(self, response: Response) -> Type[CheckedIDError]: - exception_type = self.ERROR_RESPONSE_MAPPING.get( - response.status_code, CheckedIDError + typed_response = self.process_response(response, models.OAuthToken) + + if typed_response: + self.access_token = typed_response.access_token + + return typed_response + return None + + def create_client(self, base_url: URLTypes) -> None: + self.client = httpx.AsyncClient(base_url=base_url) + + async def dossier(self, dossier_number: str) -> Optional[models.ReportResponse]: + response = await self.client.get( + url=endpoints.DossierEndpoint.url(dossier_number=dossier_number) ) - return exception_type + + return self.process_response(response, endpoints.DossierEndpoint.response) + + async def close(self) -> None: + if self.client: + await self.client.aclose() + + def open(self) -> None: + self.create_client(self.base_url) + + async def __aenter__(self) -> "ClientAsync": + """Open the httpx client""" + self.open() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + """Close the httpx client""" + await self.close() diff --git a/src/checkedid/endpoints.py b/src/checkedid/endpoints.py new file mode 100644 index 0000000..2eb3e7d --- /dev/null +++ b/src/checkedid/endpoints.py @@ -0,0 +1,23 @@ +from typing import Any +from typing import Optional +from typing import Type + +from pydantic import BaseModel + +from checkedid import models + + +class Endpoint: + path: str + request: Optional[Type[BaseModel]] + response = Optional[Type[BaseModel]] + + +class DossierEndpoint(Endpoint): + path = "/report/{dossier_number}" + request = None + response = models.ReportResponse + + @classmethod + def url(cls, **kwargs: Any) -> str: + return cls.path.format(**kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 2b687ba..4001675 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,3 +50,17 @@ def auth_client(client: Client, access_token_mock) -> Client: assert response.access_token return client + + +@pytest.fixture +def dossier_number(): + return "999999-8888800" + + +@pytest.fixture() +def dossier_response_200(respx_mock, dossier_number): + respx_mock.get("").mock( + return_value=Response( + status_code=200, json={"DossierNumber": dossier_number, "ReportPDF": ""} + ) + ) diff --git a/tests/test_async_dossiers.py b/tests/test_async_dossiers.py new file mode 100644 index 0000000..aa17789 --- /dev/null +++ b/tests/test_async_dossiers.py @@ -0,0 +1,13 @@ +import pytest + +from checkedid.client import ClientAsync + + +@pytest.mark.asyncio +async def test_dossiers(customer_code, dossier_response_200): + client = ClientAsync(customer_code) + + with client as client: + response = await client.adossier("999999-8888800") + + assert response.DossierNumber == "999999-8888800" diff --git a/tests/test_dossiers.py b/tests/test_dossiers.py index 5b19ed7..18c3cb3 100644 --- a/tests/test_dossiers.py +++ b/tests/test_dossiers.py @@ -4,13 +4,7 @@ from checkedid import errors -def test_dossier(auth_client, respx_mock): - dossier_number = "999999-8888800" - respx_mock.get("").mock( - return_value=Response( - status_code=200, json={"DossierNumber": dossier_number, "ReportPDF": ""} - ) - ) +def test_dossier(auth_client, respx_mock, dossier_response_200, dossier_number): response = auth_client.dossier(dossier_number) assert response.DossierNumber == dossier_number