diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index 7ce90539..1de2f23d 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,24 +1,23 @@ import os -from typing import Any, override +from typing import Any -from httpx import URL, AsyncClient, AsyncHTTPTransport, HTTPError, HTTPStatusError, Response -from httpx._client import USE_CLIENT_DEFAULT, UseClientDefault # noqa: PLC2701 -from httpx._types import ( # noqa: WPS235 - AuthTypes, - CookieTypes, - HeaderTypes, - QueryParamTypes, - RequestContent, - RequestData, - RequestExtensions, - RequestFiles, - TimeoutTypes, +from httpx import ( + AsyncClient, + AsyncHTTPTransport, + HTTPError, + HTTPStatusError, ) from mpt_api_client.exceptions import MPTError, transform_http_status_exception +from mpt_api_client.http.types import ( + HeaderTypes, + QueryParam, + RequestFiles, + Response, +) -class AsyncHTTPClient(AsyncClient): +class AsyncHTTPClient: """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" def __init__( @@ -49,43 +48,49 @@ def __init__( "Authorization": f"Bearer {api_token}", "Accept": "application/json", } - super().__init__( + self.httpx_client = AsyncClient( base_url=base_url, headers=base_headers, timeout=timeout, transport=AsyncHTTPTransport(retries=retries), ) - @override async def request( # noqa: WPS211 self, method: str, - url: URL | str, + url: str, *, - content: RequestContent | None = None, # noqa: WPS110 - data: RequestData | None = None, # noqa: WPS110 files: RequestFiles | None = None, json: Any | None = None, - params: QueryParamTypes | None = None, # noqa: WPS110 + query_params: QueryParam | None = None, headers: HeaderTypes | None = None, - cookies: CookieTypes | None = None, - auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, - follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, - timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, - extensions: RequestExtensions | None = None, ) -> Response: + """Perform an HTTP request. + + Args: + method: HTTP method. + url: URL to send the request to. + files: Request files. + json: Request JSON data. + query_params: Query parameters. + headers: Request headers. + + Returns: + Response object. + + Raises: + MPTError: If the request fails. + MPTApiError: If the response contains an error. + MPTHttpError: If the response contains an HTTP error. + """ try: - response = await super().request( + response = await self.httpx_client.request( method, url, - content=content, - data=data, files=files, json=json, - params=params, + params=query_params, headers=headers, - cookies=cookies, - auth=auth, ) except HTTPError as err: raise MPTError(f"HTTP Error: {err}") from err @@ -94,4 +99,8 @@ async def request( # noqa: WPS211 response.raise_for_status() except HTTPStatusError as http_status_exception: raise transform_http_status_exception(http_status_exception) from http_status_exception - return response + return Response( + headers=dict(response.headers), + status_code=response.status_code, + content=response.content, + ) diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py index 7b07a484..1e19df8b 100644 --- a/mpt_api_client/http/async_service.py +++ b/mpt_api_client/http/async_service.py @@ -1,11 +1,9 @@ from collections.abc import AsyncIterator from urllib.parse import urljoin -import httpx - from mpt_api_client.http.async_client import AsyncHTTPClient from mpt_api_client.http.base_service import ServiceBase -from mpt_api_client.http.types import QueryParam +from mpt_api_client.http.types import QueryParam, Response from mpt_api_client.models import Collection, ResourceData from mpt_api_client.models import Model as BaseModel from mpt_api_client.models.collection import ResourceList @@ -77,17 +75,17 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: break offset = items_collection.meta.pagination.next_offset() - async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: + async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: """Fetch one page of resources. Returns: - httpx.Response object. + Response object. Raises: HTTPStatusError: if the response status code is not 200. """ pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - return await self.http_client.get(self.build_url(pagination_params)) + return await self.http_client.request("get", self.build_url(pagination_params)) async def _resource_do_request( # noqa: WPS211 self, @@ -97,7 +95,7 @@ async def _resource_do_request( # noqa: WPS211 json: ResourceData | ResourceList | None = None, query_params: QueryParam | None = None, headers: dict[str, str] | None = None, - ) -> httpx.Response: + ) -> Response: """Perform an action on a specific resource using. Request with action: `HTTP_METHOD /endpoint/{resource_id}/{action}`. @@ -117,7 +115,7 @@ async def _resource_do_request( # noqa: WPS211 resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url return await self.http_client.request( - method, url, json=json, params=query_params, headers=headers + method, url, json=json, query_params=query_params, headers=headers ) async def _resource_action( diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py index c8ee2a07..d37558ea 100644 --- a/mpt_api_client/http/base_service.py +++ b/mpt_api_client/http/base_service.py @@ -1,8 +1,7 @@ import copy from typing import Any, Self -import httpx - +from mpt_api_client.http.types import Response from mpt_api_client.models import Collection, Meta from mpt_api_client.models import Model as BaseModel from mpt_api_client.rql import RQLQuery @@ -122,7 +121,7 @@ def select(self, *fields: str) -> Self: return new_client @classmethod - def _create_collection(cls, response: httpx.Response) -> Collection[Model]: + def _create_collection(cls, response: Response) -> Collection[Model]: meta = Meta.from_response(response) return Collection( resources=[ diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 61921898..915298d8 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -1,35 +1,26 @@ import os -from typing import Any, override +from typing import Any from httpx import ( - URL, - USE_CLIENT_DEFAULT, Client, HTTPError, HTTPStatusError, HTTPTransport, - Response, -) -from httpx._client import UseClientDefault -from httpx._types import ( - AuthTypes, - CookieTypes, - HeaderTypes, - QueryParamTypes, - RequestContent, - RequestData, - RequestExtensions, - TimeoutTypes, ) -from respx.types import RequestFiles from mpt_api_client.exceptions import ( MPTError, transform_http_status_exception, ) +from mpt_api_client.http.types import ( + HeaderTypes, + QueryParam, + RequestFiles, + Response, +) -class HTTPClient(Client): +class HTTPClient: """Sync HTTP client for interacting with SoftwareOne Marketplace Platform API.""" def __init__( @@ -60,43 +51,49 @@ def __init__( "Authorization": f"Bearer {api_token}", "content-type": "application/json", } - super().__init__( + self.httpx_client = Client( base_url=base_url, headers=base_headers, timeout=timeout, transport=HTTPTransport(retries=retries), ) - @override def request( # noqa: WPS211 self, method: str, - url: URL | str, + url: str, *, - content: RequestContent | None = None, # noqa: WPS110 - data: RequestData | None = None, # noqa: WPS110 files: RequestFiles | None = None, json: Any | None = None, - params: QueryParamTypes | None = None, # noqa: WPS110 + query_params: QueryParam | None = None, headers: HeaderTypes | None = None, - cookies: CookieTypes | None = None, - auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, - follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, - timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, - extensions: RequestExtensions | None = None, ) -> Response: + """Perform an HTTP request. + + Args: + method: HTTP method. + url: URL to send the request to. + files: Request files. + json: Request JSON data. + query_params: Query parameters. + headers: Request headers. + + Returns: + Response object. + + Raises: + MPTError: If the request fails. + MPTApiError: If the response contains an error. + MPTHttpError: If the response contains an HTTP error. + """ try: - response = super().request( + response = self.httpx_client.request( method, url, - content=content, - data=data, files=files, json=json, - params=params, + params=query_params, headers=headers, - cookies=cookies, - auth=auth, ) except HTTPError as err: raise MPTError(f"HTTP Error: {err}") from err @@ -105,4 +102,8 @@ def request( # noqa: WPS211 response.raise_for_status() except HTTPStatusError as http_status_exception: raise transform_http_status_exception(http_status_exception) from http_status_exception - return response + return Response( + headers=dict(response.headers), + status_code=response.status_code, + content=response.content, + ) diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py index 24ca296f..04d28ab2 100644 --- a/mpt_api_client/http/mixins.py +++ b/mpt_api_client/http/mixins.py @@ -1,9 +1,7 @@ import json from urllib.parse import urljoin -from httpx import Response -from httpx._types import FileTypes - +from mpt_api_client.http.types import FileTypes, Response from mpt_api_client.models import FileModel, ResourceData @@ -22,7 +20,7 @@ def create(self, resource_data: ResourceData) -> Model: Returns: New resource created. """ - response = self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] + response = self.http_client.request("post", self.endpoint, json=resource_data) # type: ignore[attr-defined] return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -84,7 +82,7 @@ def create( "application/json", ) - response = self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] + response = self.http_client.request("post", self.endpoint, files=files) # type: ignore[attr-defined] return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -112,7 +110,7 @@ async def create(self, resource_data: ResourceData) -> Model: Returns: New resource created. """ - response = await self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] + response = await self.http_client.request("post", self.endpoint, json=resource_data) # type: ignore[attr-defined] return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -127,7 +125,7 @@ async def delete(self, resource_id: str) -> None: resource_id: Resource ID. """ url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined] - await self.http_client.delete(url) # type: ignore[attr-defined] + await self.http_client.request("delete", url) # type: ignore[attr-defined] class AsyncUpdateMixin[Model]: @@ -175,7 +173,7 @@ async def create( "application/json", ) - response = await self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] + response = await self.http_client.request("post", self.endpoint, files=files) # type: ignore[attr-defined] return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/service.py b/mpt_api_client/http/service.py index 73bf4fe5..33be4827 100644 --- a/mpt_api_client/http/service.py +++ b/mpt_api_client/http/service.py @@ -1,11 +1,9 @@ from collections.abc import Iterator from urllib.parse import urljoin -import httpx - from mpt_api_client.http.base_service import ServiceBase from mpt_api_client.http.client import HTTPClient -from mpt_api_client.http.types import QueryParam +from mpt_api_client.http.types import QueryParam, Response from mpt_api_client.models import Collection, ResourceData from mpt_api_client.models import Model as BaseModel from mpt_api_client.models.collection import ResourceList @@ -76,17 +74,17 @@ def iterate(self, batch_size: int = 100) -> Iterator[Model]: break offset = items_collection.meta.pagination.next_offset() - def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: + def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: """Fetch one page of resources. Returns: - httpx.Response object. + Response object. Raises: HTTPStatusError: if the response status code is not 200. """ pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - return self.http_client.get(self.build_url(pagination_params)) + return self.http_client.request("get", self.build_url(pagination_params)) def _resource_do_request( # noqa: WPS211 self, @@ -96,7 +94,7 @@ def _resource_do_request( # noqa: WPS211 json: ResourceData | ResourceList | None = None, query_params: QueryParam | None = None, headers: dict[str, str] | None = None, - ) -> httpx.Response: + ) -> Response: """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. Args: @@ -116,7 +114,7 @@ def _resource_do_request( # noqa: WPS211 resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url return self.http_client.request( - method, url, json=json, params=query_params, headers=headers + method, url, json=json, query_params=query_params, headers=headers ) def _resource_action( diff --git a/mpt_api_client/http/types.py b/mpt_api_client/http/types.py index 3a0992ff..11ecef27 100644 --- a/mpt_api_client/http/types.py +++ b/mpt_api_client/http/types.py @@ -1,2 +1,42 @@ +import json +from collections.abc import Mapping, Sequence +from typing import IO, Any + PrimitiveType = str | int | float | bool | None QueryParam = dict[str, PrimitiveType] +HeaderTypes = dict[str, str] + +# Borrowed from HTTPX's "private" types. +FileContent = IO[bytes] | bytes | str +FileTypes = ( + # file (or bytes) + FileContent + | + # (filename, file (or bytes)) + tuple[str | None, FileContent] + | + # (filename, file (or bytes), content_type) + tuple[str | None, FileContent, str | None] + | + # (filename, file (or bytes), content_type, headers) + tuple[str | None, FileContent, str | None, Mapping[str, str]] # noqa: WPS221 +) +RequestFiles = Mapping[str, FileTypes] | Sequence[tuple[str, FileTypes]] # noqa: WPS221 + + +class Response: + """HTTP Response.""" + + def __init__(self, headers: HeaderTypes, status_code: int, content: bytes): # noqa: WPS110 + self.headers = headers + self.status_code = status_code + self.content = content # noqa: WPS110 + + @property + def text(self) -> str: + """Content of the response, as text.""" + return self.content.decode() + + def json(self, **kwargs: Any) -> Any: + """Return the json-encoded content of a response, if any.""" + return json.loads(self.content, **kwargs) diff --git a/mpt_api_client/models/file_model.py b/mpt_api_client/models/file_model.py index da29d4de..18636f4c 100644 --- a/mpt_api_client/models/file_model.py +++ b/mpt_api_client/models/file_model.py @@ -1,6 +1,6 @@ import re -from httpx import Response +from mpt_api_client.http.types import Response class FileModel: diff --git a/mpt_api_client/models/meta.py b/mpt_api_client/models/meta.py index e9bb2905..6dd97759 100644 --- a/mpt_api_client/models/meta.py +++ b/mpt_api_client/models/meta.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from typing import Self -from httpx import Response +from mpt_api_client.http.types import Response @dataclass diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 758809b0..c43da7a2 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,8 +1,8 @@ from typing import Any, ClassVar, Self, override from box import Box -from httpx import Response +from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta ResourceData = dict[str, Any] diff --git a/mpt_api_client/resources/notifications/batches.py b/mpt_api_client/resources/notifications/batches.py index b1e2ae1e..8d053c8f 100644 --- a/mpt_api_client/resources/notifications/batches.py +++ b/mpt_api_client/resources/notifications/batches.py @@ -49,7 +49,7 @@ def create( "application/json", ) - response = self.http_client.post(self.endpoint, files=files) + response = self.http_client.request("post", self.endpoint, files=files) return self._model_class.from_response(response) def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel: @@ -62,7 +62,9 @@ def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel: Returns: FileModel containing the attachment. """ - response = self.http_client.get(f"{self.endpoint}/{batch_id}/attachments/{attachment_id}") + response = self.http_client.request( + "get", f"{self.endpoint}/{batch_id}/attachments/{attachment_id}" + ) return FileModel(response) @@ -99,7 +101,7 @@ async def create( "application/json", ) - response = await self.http_client.post(self.endpoint, files=files) + response = await self.http_client.request("post", self.endpoint, files=files) return self._model_class.from_response(response) async def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel: @@ -112,7 +114,7 @@ async def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileM Returns: FileModel containing the attachment. """ - response = await self.http_client.get( - f"{self.endpoint}/{batch_id}/attachments/{attachment_id}" + response = await self.http_client.request( + "get", f"{self.endpoint}/{batch_id}/attachments/{attachment_id}" ) return FileModel(response) diff --git a/tests/http/test_async_client.py b/tests/http/test_async_client.py index dfc3134e..85452d90 100644 --- a/tests/http/test_async_client.py +++ b/tests/http/test_async_client.py @@ -1,28 +1,58 @@ +import json + import pytest import respx -from httpx import ConnectTimeout, Response, codes +from httpx import ConnectTimeout, Request, Response, codes from mpt_api_client.exceptions import MPTError from mpt_api_client.http.async_client import AsyncHTTPClient from tests.conftest import API_TOKEN, API_URL -def test_async_http_initialization(): - client = AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) +@pytest.fixture +def mock_request(): + return Request("GET", url="/") - assert client.base_url == API_URL - assert client.headers["Authorization"] == "Bearer test-token" - assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" +@pytest.fixture +def mock_response(mock_request): + return Response(200, json={"message": "Hello, World!"}, request=mock_request) -def test_async_http_env_initialization(monkeypatch): - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) - monkeypatch.setenv("MPT_URL", API_URL) - client = AsyncHTTPClient() +def test_async_http_initialization(mocker): + mock_async_client = mocker.patch("mpt_api_client.http.async_client.AsyncClient") - assert client.base_url == API_URL - assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" + AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) + + mock_async_client.assert_called_once_with( + base_url=API_URL, + headers={ + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": "Bearer test-token", + "Accept": "application/json", + }, + timeout=5.0, + transport=mocker.ANY, + ) + + +def test_async_env_initialization(monkeypatch, mocker): + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_URL", API_URL) + mock_async_client = mocker.patch("mpt_api_client.http.async_client.AsyncClient") + + AsyncHTTPClient() + + mock_async_client.assert_called_once_with( + base_url=API_URL, + headers={ + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": f"Bearer {API_TOKEN}", + "Accept": "application/json", + }, + timeout=5.0, + transport=mocker.ANY, + ) def test_async_http_without_token(): @@ -36,15 +66,13 @@ def test_async_http_without_url(): @respx.mock -async def test_async_http_call_success(async_http_client): - success_route = respx.get(f"{API_URL}/").mock( - return_value=Response(200, json={"message": "Hello, World!"}) - ) +async def test_async_http_call_success(async_http_client, mock_response): + success_route = respx.get(f"{API_URL}/").mock(return_value=mock_response) - success_response = await async_http_client.get("/") + success_response = await async_http_client.request("GET", "/") assert success_response.status_code == codes.OK - assert success_response.json() == {"message": "Hello, World!"} + assert json.loads(success_response.content) == {"message": "Hello, World!"} assert success_route.called @@ -53,6 +81,6 @@ async def test_async_http_call_failure(async_http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(MPTError, match="HTTP Error: Mock Timeout"): - await async_http_client.get("/timeout") + await async_http_client.request("GET", "/timeout") assert timeout_route.called diff --git a/tests/http/test_client.py b/tests/http/test_client.py index b47c663d..313c45e1 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -1,3 +1,5 @@ +import json + import pytest import respx from httpx import ConnectTimeout, Response, codes @@ -7,22 +9,40 @@ from tests.conftest import API_TOKEN, API_URL -def test_http_initialization(): - client = HTTPClient(base_url=API_URL, api_token=API_TOKEN) +def test_http_initialization(mocker): + mock_client = mocker.patch("mpt_api_client.http.client.Client") + + HTTPClient(base_url=API_URL, api_token=API_TOKEN) - assert client.base_url == API_URL - assert client.headers["Authorization"] == "Bearer test-token" - assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" + mock_client.assert_called_once_with( + base_url=API_URL, + headers={ + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": "Bearer test-token", + "content-type": "application/json", + }, + timeout=5.0, + transport=mocker.ANY, + ) -def test_env_initialization(monkeypatch): +def test_env_initialization(monkeypatch, mocker): monkeypatch.setenv("MPT_TOKEN", API_TOKEN) monkeypatch.setenv("MPT_URL", API_URL) - - client = HTTPClient() - - assert client.base_url == API_URL - assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" + mock_client = mocker.patch("mpt_api_client.http.client.Client") + + HTTPClient() + + mock_client.assert_called_once_with( + base_url=API_URL, + headers={ + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": f"Bearer {API_TOKEN}", + "content-type": "application/json", + }, + timeout=5.0, + transport=mocker.ANY, + ) def test_http_without_token(): @@ -41,10 +61,10 @@ def test_http_call_success(http_client): return_value=Response(200, json={"message": "Hello, World!"}) ) - success_response = http_client.get("/") + success_response = http_client.request("GET", "/") assert success_response.status_code == codes.OK - assert success_response.json() == {"message": "Hello, World!"} + assert json.loads(success_response.content) == {"message": "Hello, World!"} assert success_route.called @@ -53,6 +73,6 @@ def test_http_call_failure(http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(MPTError, match="HTTP Error: Mock Timeout"): - http_client.get("/timeout") + http_client.request("GET", "/timeout") assert timeout_route.called diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index db3a7942..10ee6c5f 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -52,6 +52,19 @@ def test_api_error_str_and_repr(): ) +def test_api_error_str_no_errors(): + payload = { + "status": "400", + "title": "Bad Request", + "detail": "Invalid input", + "traceId": "abc123", + } + + exception = MPTAPIError(status_code=400, payload=payload) + + assert str(exception) == "400 Bad Request - Invalid input (abc123)" + + def test_transform_http_status_exception(): payload = { "status": "400",