diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index a27b5c66..850e1ed6 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -15,6 +15,10 @@ AsyncItemGroupsService, ItemGroupsService, ) +from mpt_api_client.resources.catalog.products_media import ( + AsyncMediaService, + MediaService, +) from mpt_api_client.resources.catalog.products_parameter_groups import ( AsyncParameterGroupsService, ParameterGroupsService, @@ -59,6 +63,12 @@ def parameter_groups(self, product_id: str) -> ParameterGroupsService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def media(self, product_id: str) -> MediaService: + """Return media service.""" + return MediaService( + 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( @@ -88,6 +98,12 @@ def parameter_groups(self, product_id: str) -> AsyncParameterGroupsService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def media(self, product_id: str) -> AsyncMediaService: + """Return media service.""" + return AsyncMediaService( + 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_media.py b/mpt_api_client/resources/catalog/products_media.py new file mode 100644 index 00000000..4ce4bb47 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_media.py @@ -0,0 +1,188 @@ +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 Media(Model): + """Media resource.""" + + +class MediaServiceConfig: + """Media service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/media" + _model_class = Media + _collection_key = "data" + + +class MediaService( + CreateMixin[Media], + DeleteMixin, + UpdateMixin[Media], + PublishableMixin[Media], + Service[Media], + MediaServiceConfig, +): + """Media service.""" + + @override + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Media: + """Create Media resource. + + Currently are two types of media resources available image and video. + + Video: + resource_data: + { + "name": "SomeMediaFile", + "description":"Some media description", + "mediaType": "Video", + "url": http://www.somemedia.com/somevideo.avi, + "displayOrder": 1 + } + files: Add an image with the video thumbnail + + Image: + resource_data: + { + "name": "SomeMediaFile", + "description":"Some media description", + "mediaType": "Video", + "displayOrder": 1 + } + files: The image itself + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Media resource. + """ + files = files or {} + + # Note: This is a workaround to fulfill MPT API request format + # + # HTTPx does not support sending json and files in the same call + # currently only supports sending form-data and files in the same call. + # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads + # + # MPT API expects files and data to be submitted in a multipart form-data upload. + # + # Current workaround is to send the json data as an unnamed file. + # This ends adding the json as payload multipart data. + # + # json.dumps is setup using the same params of httpx json encoder to produce the same + # encodings. + + if resource_data: + files["_media_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Media.from_response(response) + + def download(self, media_id: str) -> FileModel: + """Download the media file for the given media ID. + + Args: + media_id: Media ID. + + Returns: + Media file. + """ + response: Response = self._resource_do_request( + media_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + +class AsyncMediaService( + AsyncCreateMixin[Media], + AsyncDeleteMixin, + AsyncUpdateMixin[Media], + AsyncPublishableMixin[Media], + AsyncService[Media], + MediaServiceConfig, +): + """Media service.""" + + @override + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Media: + """Create Media resource. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Media resource. + """ + files = files or {} + + # Note: This is a workaround to fulfill MPT API request format + # + # HTTPx does not support sending json and files in the same call + # currently only supports sending form-data and files in the same call. + # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads + # + # MPT API expects files and data to be submitted in a multipart form-data upload. + # + # Current workaround is to send the json data as an unnamed file. + # This ends adding the json as payload multipart data. + # + # json.dumps is setup using the same params of httpx json encoder to produce the same + # encodings. + + if resource_data: + files["_media_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 Media.from_response(response) + + async def download(self, media_id: str) -> FileModel: + """Download the media file for the given media ID. + + Args: + media_id: Media ID. + + Returns: + Media file. + """ + response = await self._resource_do_request(media_id, method="GET", headers={"Accept": "*"}) + return FileModel(response) diff --git a/setup.cfg b/setup.cfg index a584cbbb..f67d1233 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,11 +33,12 @@ extend-ignore = per-file-ignores = mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214 - mpt_api_client/resources/catalog/products.py: WPS215 + mpt_api_client/resources/catalog/products.py: WPS204 WPS215 mpt_api_client/resources/catalog/items.py: WPS215 mpt_api_client/resources/catalog/products_item_groups.py: WPS215 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 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 b34c854d..7d30ec00 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -5,6 +5,10 @@ AsyncItemGroupsService, ItemGroupsService, ) +from mpt_api_client.resources.catalog.products_media import ( + AsyncMediaService, + MediaService, +) from mpt_api_client.resources.catalog.products_parameter_groups import ( AsyncParameterGroupsService, ParameterGroupsService, @@ -44,6 +48,7 @@ def test_async_mixins_present(async_products_service, method): [ ("item_groups", ItemGroupsService), ("parameter_groups", ParameterGroupsService), + ("media", MediaService), ("product_parameters", ParametersService), ], ) @@ -59,6 +64,7 @@ def test_property_services(products_service, service_method, expected_service_cl [ ("item_groups", AsyncItemGroupsService), ("parameter_groups", AsyncParameterGroupsService), + ("media", AsyncMediaService), ("product_parameters", AsyncParametersService), ], ) diff --git a/tests/resources/catalog/test_products_media.py b/tests/resources/catalog/test_products_media.py new file mode 100644 index 00000000..dcbbaaea --- /dev/null +++ b/tests/resources/catalog/test_products_media.py @@ -0,0 +1,204 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_media import ( + AsyncMediaService, + MediaService, +) + + +@pytest.fixture +def media_service(http_client): + return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_media_service(async_http_client): + return AsyncMediaService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(media_service): + assert media_service.endpoint == "/public/v1/catalog/products/PRD-001/media" + + +def test_async_endpoint(async_media_service): + assert async_media_service.endpoint == "/public/v1/catalog/products/PRD-001/media" + + +async def test_async_create(async_media_service): + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=200, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = await async_media_service.create({"name": "Product image"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_media_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product image"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +async def test_async_create_no_data(async_media_service): + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=200, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = await async_media_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +async def test_async_download(async_media_service): + media_content = b"Image file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + + downloaded_file = await async_media_service.download("MED-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == media_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "product_image.jpg" + + +def test_download(media_service): + media_content = b"Image file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + + downloaded_file = media_service.download("MED-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == media_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "product_image.jpg" + + +def test_create(media_service): + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=200, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = media_service.create({"name": "Product image"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_media_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product image"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +def test_create_no_data(media_service): + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=200, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = media_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_methods_present(media_service, method): + assert hasattr(media_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_async_methods_present(async_media_service, method): + assert hasattr(async_media_service, method)