diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 850e1ed6..1792a49e 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -11,6 +11,10 @@ AsyncPublishableMixin, PublishableMixin, ) +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -69,6 +73,12 @@ def media(self, product_id: str) -> MediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> DocumentService: + """Return documents service.""" + return DocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> ParametersService: """Return product_parameters service.""" return ParametersService( @@ -104,6 +114,12 @@ def media(self, product_id: str) -> AsyncMediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> AsyncDocumentService: + """Return documents service.""" + return AsyncDocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> AsyncParametersService: """Return product_parameters service.""" return AsyncParametersService( diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py new file mode 100644 index 00000000..80da5015 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -0,0 +1,143 @@ +import json +from typing import override + +from httpx import Response +from httpx._types import FileTypes + +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import FileModel, Model, ResourceData +from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin + + +def _json_to_file_payload(resource_data: ResourceData) -> bytes: + return json.dumps( + resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +class Document(Model): + """Document resource.""" + + +class DocumentServiceConfig: + """Document service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/documents" + _model_class = Document + _collection_key = "data" + + +class DocumentService( + CreateMixin[Document], + DeleteMixin, + UpdateMixin[Document], + PublishableMixin[Document], + Service[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response: Response = self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + +class AsyncDocumentService( + AsyncCreateMixin[Document], + AsyncDeleteMixin, + AsyncUpdateMixin[Document], + AsyncPublishableMixin[Document], + AsyncService[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = await self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + async def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response = await self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) diff --git a/setup.cfg b/setup.cfg index f67d1233..8ee2d8ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,7 @@ per-file-ignores = mpt_api_client/resources/catalog/products_parameter_groups.py: WPS215 mpt_api_client/resources/catalog/products_parameters.py: WPS215 mpt_api_client/resources/catalog/products_media.py: WPS215 + mpt_api_client/resources/catalog/products_documents.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py index 7d30ec00..81d070e9 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -1,6 +1,10 @@ import pytest from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -49,6 +53,7 @@ def test_async_mixins_present(async_products_service, method): ("item_groups", ItemGroupsService), ("parameter_groups", ParameterGroupsService), ("media", MediaService), + ("documents", DocumentService), ("product_parameters", ParametersService), ], ) @@ -65,6 +70,7 @@ def test_property_services(products_service, service_method, expected_service_cl ("item_groups", AsyncItemGroupsService), ("parameter_groups", AsyncParameterGroupsService), ("media", AsyncMediaService), + ("documents", AsyncDocumentService), ("product_parameters", AsyncParametersService), ], ) diff --git a/tests/resources/catalog/test_products_documents.py b/tests/resources/catalog/test_products_documents.py new file mode 100644 index 00000000..2d8a3c2c --- /dev/null +++ b/tests/resources/catalog/test_products_documents.py @@ -0,0 +1,190 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) + + +@pytest.fixture +def document_service(http_client): + return DocumentService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_document_service(async_http_client): + return AsyncDocumentService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(document_service): + assert document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +def test_async_endpoint(async_document_service): + assert async_document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +async def test_async_create(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create( + {"name": "Product document"}, files=files + ) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_create_no_data(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_download(async_document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = await async_document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf" + + +def test_create(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create({"name": "Product document"}, files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_create_no_data(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_download(document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf"