From 9248a7e6edb22abf1daf4efff69246802cc4122c Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 15 Aug 2025 10:41:19 +0100 Subject: [PATCH] MPT-12326 Implement base resource client --- mpt_api_client/http/resource.py | 104 ++++++++++++++++ tests/http/collection/conftest.py | 7 +- tests/http/conftest.py | 5 + tests/http/resource/conftest.py | 30 +++++ .../resource/test_resource_client_fetch.py | 114 ++++++++++++++++++ .../resource/test_resource_client_update.py | 85 +++++++++++++ 6 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 mpt_api_client/http/resource.py create mode 100644 tests/http/resource/conftest.py create mode 100644 tests/http/resource/test_resource_client_fetch.py create mode 100644 tests/http/resource/test_resource_client_update.py diff --git a/mpt_api_client/http/resource.py b/mpt_api_client/http/resource.py new file mode 100644 index 00000000..51fb4805 --- /dev/null +++ b/mpt_api_client/http/resource.py @@ -0,0 +1,104 @@ +from abc import ABC +from typing import Any, ClassVar, Self, override + +from mpt_api_client.http.client import MPTClient +from mpt_api_client.models import Resource + + +class ResourceBaseClient[ResourceType: Resource](ABC): # noqa: WPS214 + """Client for RESTful resources.""" + + _endpoint: str + _resource_class: type[Resource] + _safe_attributes: ClassVar[set[str]] = {"mpt_client_", "resource_id_", "resource_"} + + def __init__(self, client: MPTClient, resource_id: str) -> None: + self.mpt_client_ = client # noqa: WPS120 + self.resource_id_ = resource_id # noqa: WPS120 + self.resource_: Resource | None = None # noqa: WPS120 + + def __getattr__(self, attribute: str) -> Any: + """Returns the resource data.""" + self._ensure_resource_is_fetched() + return self.resource_.__getattr__(attribute) # type: ignore[union-attr] + + @property + def resource_url(self) -> str: + """Returns the resource URL.""" + return f"{self._endpoint}/{self.resource_id_}" + + @override + def __setattr__(self, attribute: str, attribute_value: Any) -> None: + if attribute in self._safe_attributes: + object.__setattr__(self, attribute, attribute_value) + return + self._ensure_resource_is_fetched() + self.resource_.__setattr__(attribute, attribute_value) + + def fetch(self) -> Resource: + """Fetch a specific resource using `GET /endpoint/{resource_id}`. + + It fetches and caches the resource. + + Returns: + The fetched resource. + """ + response = self.mpt_client_.get(self.resource_url) + response.raise_for_status() + + self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 + return self.resource_ + + def update(self, resource_data: dict[str, Any]) -> Resource: + """Update a specific in the API and catches the result as a current resource. + + Args: + resource_data: The updated resource data. + + Returns: + The updated resource. + + Examples: + updated_contact = contact.update({"name": "New Name"}) + + + """ + response = self.mpt_client_.put(self.resource_url, json=resource_data) + response.raise_for_status() + + self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 + return self.resource_ + + def save(self) -> Self: + """Save the current state of the resource to the api using the update method. + + Raises: + ValueError: If the resource has not been set. + + Examples: + contact.name = "New Name" + contact.save() + + """ + if not self.resource_: + raise ValueError("Unable to save resource that has not been set.") + self.update(self.resource_.to_dict()) + return self + + def delete(self) -> None: + """Delete the resource using `DELETE /endpoint/{resource_id}`. + + Raises: + HTTPStatusError: If the deletion fails. + + Examples: + contact.delete() + """ + response = self.mpt_client_.delete(self.resource_url) + response.raise_for_status() + + self.resource_ = None # noqa: WPS120 + + def _ensure_resource_is_fetched(self) -> None: + if not self.resource_: + self.fetch() diff --git a/tests/http/collection/conftest.py b/tests/http/collection/conftest.py index f4822e40..dc211554 100644 --- a/tests/http/collection/conftest.py +++ b/tests/http/collection/conftest.py @@ -1,11 +1,8 @@ import pytest from mpt_api_client.http.collection import CollectionBaseClient -from mpt_api_client.models import Collection, Resource - - -class DummyResource(Resource): - """Dummy resource for testing.""" +from mpt_api_client.models import Collection +from tests.http.conftest import DummyResource class DummyCollectionClient(CollectionBaseClient[DummyResource]): diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 80aca9e2..e2840523 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,11 +1,16 @@ import pytest from mpt_api_client.http.client import MPTClient +from mpt_api_client.models import Resource API_TOKEN = "test-token" API_URL = "https://api.example.com" +class DummyResource(Resource): + """Dummy resource for testing.""" + + @pytest.fixture def mpt_client(): return MPTClient(base_url=API_URL, api_token=API_TOKEN) diff --git a/tests/http/resource/conftest.py b/tests/http/resource/conftest.py new file mode 100644 index 00000000..c969cb17 --- /dev/null +++ b/tests/http/resource/conftest.py @@ -0,0 +1,30 @@ +import pytest + +from mpt_api_client.http.client import MPTClient +from mpt_api_client.http.resource import ResourceBaseClient +from tests.http.conftest import DummyResource + + +class DummyResourceClient(ResourceBaseClient[DummyResource]): + _endpoint = "/api/v1/test-resource" + _resource_class = DummyResource + + +@pytest.fixture +def api_url(): + return "https://api.example.com" + + +@pytest.fixture +def api_token(): + return "test-token" + + +@pytest.fixture +def mpt_client(api_url, api_token): + return MPTClient(base_url=api_url, api_token=api_token) + + +@pytest.fixture +def resource_client(mpt_client): + return DummyResourceClient(client=mpt_client, resource_id="RES-123") diff --git a/tests/http/resource/test_resource_client_fetch.py b/tests/http/resource/test_resource_client_fetch.py new file mode 100644 index 00000000..646dbbde --- /dev/null +++ b/tests/http/resource/test_resource_client_fetch.py @@ -0,0 +1,114 @@ +import httpx +import pytest +import respx + + +def test_fetch_success(resource_client): + expected_response = httpx.Response( + httpx.codes.OK, + json={"data": {"id": "RES-123", "name": "Test Resource", "status": "active"}}, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + resource = resource_client.fetch() + + assert resource.to_dict() == {"id": "RES-123", "name": "Test Resource", "status": "active"} + assert mock_route.called + assert mock_route.call_count == 1 + assert resource_client.resource_ is not None + + +def test_get_attribute(resource_client): + expected_response = httpx.Response( + httpx.codes.OK, + json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + assert resource_client.id == "RES-123" + assert resource_client.contact.name == "Albert" + assert mock_route.call_count == 1 + + +def test_set_attribute(resource_client): + expected_response = httpx.Response( + httpx.codes.OK, + json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, + ) + + with respx.mock: + respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + resource_client.status = "disabled" + resource_client.contact.name = "Alice" + + assert resource_client.status == "disabled" + assert resource_client.contact.name == "Alice" + + +def test_fetch_not_found(resource_client): + error_response = httpx.Response(httpx.codes.NOT_FOUND, json={"error": "Resource not found"}) + + with respx.mock: + respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=error_response + ) + + with pytest.raises(httpx.HTTPStatusError): + resource_client.fetch() + + +def test_fetch_server_error(resource_client): + error_response = httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal server error"} + ) + + with respx.mock: + respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=error_response + ) + + with pytest.raises(httpx.HTTPStatusError): + resource_client.fetch() + + +def test_fetch_with_special_characters_in_id(resource_client): + expected_response = httpx.Response( + httpx.codes.OK, json={"data": {"id": "RES-123", "name": "Special Resource"}} + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + resource = resource_client.fetch() + + assert resource.to_dict() == {"id": "RES-123", "name": "Special Resource"} + assert mock_route.called + + +def test_fetch_verifies_correct_url_construction(resource_client): + expected_response = httpx.Response(httpx.codes.OK, json={"data": {"id": "RES-123"}}) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + resource_client.fetch() + + request = mock_route.calls[0].request + + assert request.method == "GET" + assert str(request.url) == "https://api.example.com/api/v1/test-resource/RES-123" diff --git a/tests/http/resource/test_resource_client_update.py b/tests/http/resource/test_resource_client_update.py new file mode 100644 index 00000000..97524926 --- /dev/null +++ b/tests/http/resource/test_resource_client_update.py @@ -0,0 +1,85 @@ +import httpx +import pytest +import respx + + +def test_update_resource_successfully(resource_client): + update_data = {"name": "Updated Resource Name", "status": "modified", "version": 2} + expected_response = httpx.Response( + httpx.codes.OK, + json={ + "data": { + "id": "RES-123", + "name": "Updated Resource Name", + "status": "modified", + "version": 2, + } + }, + ) + + with respx.mock: + mock_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=expected_response + ) + + resource = resource_client.update(update_data) + + assert resource.to_dict() == { + "id": "RES-123", + "name": "Updated Resource Name", + "status": "modified", + "version": 2, + } + assert mock_route.called + assert mock_route.call_count == 1 + + +def test_save_resource_successfully(resource_client): + fetch_response = httpx.Response( + httpx.codes.OK, + json={"data": {"id": "RES-123", "name": "Original Name", "status": "active"}}, + ) + save_response = httpx.Response( + httpx.codes.OK, + json={"data": {"id": "RES-123", "name": "Modified Name", "status": "active"}}, + ) + + with respx.mock: + respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=fetch_response + ) + mock_put_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( + return_value=save_response + ) + + resource_client.fetch() + resource_client.name = "Modified Name" + resource_client.save() + + assert resource_client.resource_.to_dict() == { + "id": "RES-123", + "name": "Modified Name", + "status": "active", + } + assert mock_put_route.called + assert mock_put_route.call_count == 1 + + +def test_save_raises_error_when_resource_not_set(resource_client): + with pytest.raises(ValueError, match="Unable to save resource that has not been set"): + resource_client.save() + + +def test_delete_resource_successfully(resource_client): + delete_response = httpx.Response(httpx.codes.NO_CONTENT) + + with respx.mock: + mock_delete_route = respx.delete( + "https://api.example.com/api/v1/test-resource/RES-123" + ).mock(return_value=delete_response) + + resource_client.delete() + + assert resource_client.resource_ is None + assert mock_delete_route.called + assert mock_delete_route.call_count == 1