diff --git a/mpt_api_client/http/__init__.py b/mpt_api_client/http/__init__.py index e5affc26..cb7f0135 100644 --- a/mpt_api_client/http/__init__.py +++ b/mpt_api_client/http/__init__.py @@ -1,6 +1,16 @@ from mpt_api_client.http.async_client import AsyncHTTPClient from mpt_api_client.http.async_service import AsyncService from mpt_api_client.http.client import HTTPClient +from mpt_api_client.http.mixins import AsyncCreateMixin, AsyncDeleteMixin, CreateMixin, DeleteMixin from mpt_api_client.http.service import Service -__all__ = ["AsyncHTTPClient", "AsyncService", "HTTPClient", "Service"] # noqa: WPS410 +__all__ = [ # noqa: WPS410 + "AsyncCreateMixin", + "AsyncDeleteMixin", + "AsyncHTTPClient", + "AsyncService", + "CreateMixin", + "DeleteMixin", + "HTTPClient", + "Service", +] diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py index d23aeaf4..765cdefd 100644 --- a/mpt_api_client/http/async_service.py +++ b/mpt_api_client/http/async_service.py @@ -5,6 +5,7 @@ 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.models import Collection, ResourceData from mpt_api_client.models import Model as BaseModel from mpt_api_client.models.collection import ResourceList @@ -23,7 +24,11 @@ class AsyncService[Model: BaseModel](ServiceBase[AsyncHTTPClient, Model]): # no """ async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: - """Fetch one page of resources.""" + """Fetch one page of resources. + + Returns: + Collection of resources. + """ response = await self._fetch_page_as_response(limit=limit, offset=offset) return self._create_collection(response) @@ -72,30 +77,32 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: break offset = items_collection.meta.pagination.next_offset() - async def create(self, resource_data: ResourceData) -> Model: - """Create a new resource using `POST /endpoint`. + async def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + select: List of fields to select. Returns: - New resource created. + Resource object. """ - response = await self.http_client.post(self._endpoint, json=resource_data) - response.raise_for_status() + if isinstance(select, list): + select = ",".join(select) if select else None + return await self._resource_action(resource_id=resource_id, query_params={"select": select}) - return self._model_class.from_response(response) + async def update(self, resource_id: str, resource_data: ResourceData) -> Model: + """Update a resource using `PUT /endpoint/{resource_id}`. - async def get(self, resource_id: str) -> Model: - """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" - return await self._resource_action(resource_id=resource_id) + Args: + resource_id: Resource ID. + resource_data: Resource data. - async def update(self, resource_id: str, resource_data: ResourceData) -> Model: - """Update a resource using `PUT /endpoint/{resource_id}`.""" - return await self._resource_action(resource_id, "PUT", json=resource_data) + Returns: + Resource object. - async def delete(self, resource_id: str) -> None: - """Delete resource using `DELETE /endpoint/{resource_id}`.""" - url = urljoin(f"{self._endpoint}/", resource_id) - response = await self.http_client.delete(url) - response.raise_for_status() + """ + return await self._resource_action(resource_id, "PUT", json=resource_data) async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -118,6 +125,7 @@ async def _resource_do_request( method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, + query_params: QueryParam | None = None, ) -> httpx.Response: """Perform an action on a specific resource using. @@ -129,13 +137,14 @@ async def _resource_do_request( method: The HTTP method to use. action: The action name to use. json: The updated resource data. + query_params: Additional query parameters. Raises: HTTPError: If the action fails. """ resource_url = urljoin(f"{self._endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = await self.http_client.request(method, url, json=json) + response = await self.http_client.request(method, url, json=json, params=query_params) response.raise_for_status() return response @@ -145,7 +154,18 @@ async def _resource_action( method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, + query_params: QueryParam | None = None, ) -> Model: - """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" - response = await self._resource_do_request(resource_id, method, action, json=json) + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + query_params: Additional query parameters. + """ + response = await self._resource_do_request( + resource_id, method, action, json=json, query_params=query_params + ) return self._model_class.from_response(response) diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py new file mode 100644 index 00000000..5c32baff --- /dev/null +++ b/mpt_api_client/http/mixins.py @@ -0,0 +1,60 @@ +from urllib.parse import urljoin + +from mpt_api_client.models import ResourceData + + +class CreateMixin[Model]: + """Create resource mixin.""" + + def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = self.http_client.post(self._endpoint, json=resource_data) # type: ignore[attr-defined] + response.raise_for_status() + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class DeleteMixin: + """Delete resource mixin.""" + + def delete(self, resource_id: str) -> None: + """Delete resource using `DELETE /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + """ + response = self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined] + response.raise_for_status() + + +class AsyncCreateMixin[Model]: + """Create resource mixin.""" + + async def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = await self.http_client.post(self._endpoint, json=resource_data) # type: ignore[attr-defined] + response.raise_for_status() + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class AsyncDeleteMixin: + """Delete resource mixin.""" + + async def delete(self, resource_id: str) -> None: + """Delete resource using `DELETE /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + """ + url = urljoin(f"{self._endpoint}/", resource_id) # type: ignore[attr-defined] + response = await self.http_client.delete(url) # type: ignore[attr-defined] + response.raise_for_status() diff --git a/mpt_api_client/http/service.py b/mpt_api_client/http/service.py index a2ab693c..febdaf31 100644 --- a/mpt_api_client/http/service.py +++ b/mpt_api_client/http/service.py @@ -5,6 +5,7 @@ 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.models import Collection, ResourceData from mpt_api_client.models import Model as BaseModel from mpt_api_client.models.collection import ResourceList @@ -75,29 +76,33 @@ def iterate(self, batch_size: int = 100) -> Iterator[Model]: break offset = items_collection.meta.pagination.next_offset() - def create(self, resource_data: ResourceData) -> Model: - """Create a new resource using `POST /endpoint`. + def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + select: List of fields to select. Returns: - New resource created. + Resource object. """ - response = self.http_client.post(self._endpoint, json=resource_data) - response.raise_for_status() + if isinstance(select, list): + select = ",".join(select) if select else None - return self._model_class.from_response(response) - - def get(self, resource_id: str) -> Model: - """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" - return self._resource_action(resource_id=resource_id) + return self._resource_action(resource_id=resource_id, query_params={"select": select}) def update(self, resource_id: str, resource_data: ResourceData) -> Model: - """Update a resource using `PUT /endpoint/{resource_id}`.""" - return self._resource_action(resource_id, "PUT", json=resource_data) + """Update a resource using `PUT /endpoint/{resource_id}`. - def delete(self, resource_id: str) -> None: - """Delete the resoruce using `DELETE /endpoint/{resource_id}`.""" - response = self._resource_do_request(resource_id, "DELETE") - response.raise_for_status() + Args: + resource_id: Resource ID. + resource_data: Resource data. + + Returns: + Resource object. + + """ + return self._resource_action(resource_id, "PUT", json=resource_data) def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -120,6 +125,7 @@ def _resource_do_request( method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, + query_params: QueryParam | None = None, ) -> httpx.Response: """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. @@ -128,6 +134,7 @@ def _resource_do_request( method: The HTTP method to use. action: The action name to use. json: The updated resource data. + query_params: Additional query parameters. Returns: HTTP response object. @@ -137,7 +144,7 @@ def _resource_do_request( """ resource_url = urljoin(f"{self._endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = self.http_client.request(method, url, json=json) + response = self.http_client.request(method, url, json=json, params=query_params) response.raise_for_status() return response @@ -147,7 +154,18 @@ def _resource_action( method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, + query_params: QueryParam | None = None, ) -> Model: - """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" - response = self._resource_do_request(resource_id, method, action, json=json) + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + query_params: Additional query parameters. + """ + response = self._resource_do_request( + resource_id, method, action, json=json, query_params=query_params + ) return self._model_class.from_response(response) diff --git a/mpt_api_client/http/types.py b/mpt_api_client/http/types.py new file mode 100644 index 00000000..3a0992ff --- /dev/null +++ b/mpt_api_client/http/types.py @@ -0,0 +1,2 @@ +PrimitiveType = str | int | float | bool | None +QueryParam = dict[str, PrimitiveType] diff --git a/mpt_api_client/resources/commerce/orders.py b/mpt_api_client/resources/commerce/orders.py index a2671b0b..7878bff4 100644 --- a/mpt_api_client/resources/commerce/orders.py +++ b/mpt_api_client/resources/commerce/orders.py @@ -1,4 +1,11 @@ -from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncService, + CreateMixin, + DeleteMixin, + Service, +) from mpt_api_client.models import Model, ResourceData @@ -14,8 +21,13 @@ class OrdersServiceConfig: _collection_key = "data" -class OrdersService(Service[Order], OrdersServiceConfig): - """Orders client.""" +class OrdersService( # noqa: WPS215 + CreateMixin[Order], + DeleteMixin, + Service[Order], + OrdersServiceConfig, +): + """Orders service.""" def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: """Switch order to validate state. @@ -84,8 +96,13 @@ def template(self, resource_id: str) -> str: return response.text -class AsyncOrdersService(AsyncService[Order], OrdersServiceConfig): - """Async Orders client.""" +class AsyncOrdersService( # noqa: WPS215 + AsyncCreateMixin[Order], + AsyncDeleteMixin, + AsyncService[Order], + OrdersServiceConfig, +): + """Async Orders service.""" async def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: """Switch order to validate state. diff --git a/tests/http/conftest.py b/tests/http/conftest.py index dc1b3507..ced339ed 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -2,17 +2,23 @@ import pytest from mpt_api_client import RQLQuery -from mpt_api_client.http.async_service import AsyncService -from mpt_api_client.http.service import Service +from mpt_api_client.http import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncService, + CreateMixin, + DeleteMixin, + Service, +) from tests.conftest import DummyModel -class DummyService(Service[DummyModel]): +class DummyService(CreateMixin[DummyModel], DeleteMixin, Service[DummyModel]): _endpoint = "/api/v1/test" _model_class = DummyModel -class AsyncDummyService(AsyncService[DummyModel]): +class AsyncDummyService(AsyncCreateMixin[DummyModel], AsyncDeleteMixin, AsyncService[DummyModel]): _endpoint = "/api/v1/test" _model_class = DummyModel diff --git a/tests/http/test_async_service.py b/tests/http/test_async_service.py index cf188994..e71fcc96 100644 --- a/tests/http/test_async_service.py +++ b/tests/http/test_async_service.py @@ -8,6 +8,39 @@ from tests.http.conftest import AsyncDummyService +async def test_async_create_mixin(async_dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = await async_dummy_service.create(resource_data) + + assert created_resource.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data + + +async def test_async_delete_mixin(async_dummy_service): # noqa: WPS210 + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + await async_dummy_service.delete("RES-123") + + assert mock_route.call_count == 1 + + async def test_async_fetch_one_success(async_dummy_service, single_result_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( @@ -244,39 +277,6 @@ async def test_async_iterate_lazy_evaluation(async_dummy_service): assert mock_route.call_count == 1 -async def test_async_create_resource(async_dummy_service): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = await async_dummy_service.create(resource_data) - - assert created_resource.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data - - -async def test_async_delete_resource(async_dummy_service): # noqa: WPS210 - delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) - - with respx.mock: - mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( - return_value=delete_response - ) - - await async_dummy_service.delete("RES-123") - - assert mock_route.call_count == 1 - - async def test_async_update_resource(async_dummy_service): # noqa: WPS210 resource_data = {"name": "Test Resource", "status": "active"} update_response = httpx.Response(httpx.codes.OK, json=resource_data) @@ -300,6 +300,6 @@ async def test_async_get(async_dummy_service): return_value=httpx.Response(httpx.codes.OK, json=resource_data) ) - resource = await async_dummy_service.get("RES-123") + resource = await async_dummy_service.get("RES-123", select=["id", "name"]) assert isinstance(resource, DummyModel) assert resource.to_dict() == resource_data diff --git a/tests/http/test_service.py b/tests/http/test_service.py index f9d665a6..7c45e22e 100644 --- a/tests/http/test_service.py +++ b/tests/http/test_service.py @@ -9,6 +9,38 @@ from tests.http.conftest import DummyService +def test_sync_create_mixin(dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = dummy_service.create(resource_data) + + assert created_resource.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data + + +def test_sync_delete_mixin(dummy_service): + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + dummy_service.delete("RES-123") + + assert mock_route.call_count == 1 + + def test_sync_fetch_one_success(dummy_service, single_result_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( @@ -101,7 +133,7 @@ def test_sync_get(dummy_service): return_value=httpx.Response(httpx.codes.OK, json=resource_data) ) - resource = dummy_service.get("RES-123") + resource = dummy_service.get("RES-123", select=["id", "name"]) assert isinstance(resource, DummyModel) assert resource.to_dict() == resource_data @@ -266,38 +298,6 @@ def test_sync_iterate_handles_api_errors(dummy_service): list(iterator) -def test_sync_create_resource(dummy_service): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = dummy_service.create(resource_data) - - assert created_resource.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data - - -def test_sync_delete_resource(dummy_service): - delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) - with respx.mock: - mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( - return_value=delete_response - ) - - dummy_service.delete("RES-123") - - assert mock_route.call_count == 1 - - def test_sync_update_resource(dummy_service): resource_data = {"name": "Test Resource", "status": "active"} update_response = httpx.Response(httpx.codes.OK, json=resource_data)