Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions mpt_api_client/http/resource.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 2 additions & 5 deletions tests/http/collection/conftest.py
Original file line number Diff line number Diff line change
@@ -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]):
Expand Down
5 changes: 5 additions & 0 deletions tests/http/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 30 additions & 0 deletions tests/http/resource/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
114 changes: 114 additions & 0 deletions tests/http/resource/test_resource_client_fetch.py
Original file line number Diff line number Diff line change
@@ -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"
85 changes: 85 additions & 0 deletions tests/http/resource/test_resource_client_update.py
Original file line number Diff line number Diff line change
@@ -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