Skip to content

Commit 2157c1f

Browse files
authored
MPT-12326 Implement base resource client (#11)
2 parents a86a677 + df74cb9 commit 2157c1f

File tree

6 files changed

+340
-5
lines changed

6 files changed

+340
-5
lines changed

mpt_api_client/http/resource.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from abc import ABC
2+
from typing import Any, ClassVar, Self, override
3+
4+
from mpt_api_client.http.client import MPTClient
5+
from mpt_api_client.models import Resource
6+
7+
8+
class ResourceBaseClient[ResourceType: Resource](ABC): # noqa: WPS214
9+
"""Client for RESTful resources."""
10+
11+
_endpoint: str
12+
_resource_class: type[Resource]
13+
_safe_attributes: ClassVar[set[str]] = {"mpt_client_", "resource_id_", "resource_"}
14+
15+
def __init__(self, client: MPTClient, resource_id: str) -> None:
16+
self.mpt_client_ = client # noqa: WPS120
17+
self.resource_id_ = resource_id # noqa: WPS120
18+
self.resource_: Resource | None = None # noqa: WPS120
19+
20+
def __getattr__(self, attribute: str) -> Any:
21+
"""Returns the resource data."""
22+
self._ensure_resource_is_fetched()
23+
return self.resource_.__getattr__(attribute) # type: ignore[union-attr]
24+
25+
@property
26+
def resource_url(self) -> str:
27+
"""Returns the resource URL."""
28+
return f"{self._endpoint}/{self.resource_id_}"
29+
30+
@override
31+
def __setattr__(self, attribute: str, attribute_value: Any) -> None:
32+
if attribute in self._safe_attributes:
33+
object.__setattr__(self, attribute, attribute_value)
34+
return
35+
self._ensure_resource_is_fetched()
36+
self.resource_.__setattr__(attribute, attribute_value)
37+
38+
def fetch(self) -> Resource:
39+
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.
40+
41+
It fetches and caches the resource.
42+
43+
Returns:
44+
The fetched resource.
45+
"""
46+
response = self.mpt_client_.get(self.resource_url)
47+
response.raise_for_status()
48+
49+
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
50+
return self.resource_
51+
52+
def update(self, resource_data: dict[str, Any]) -> Resource:
53+
"""Update a specific in the API and catches the result as a current resource.
54+
55+
Args:
56+
resource_data: The updated resource data.
57+
58+
Returns:
59+
The updated resource.
60+
61+
Examples:
62+
updated_contact = contact.update({"name": "New Name"})
63+
64+
65+
"""
66+
response = self.mpt_client_.put(self.resource_url, json=resource_data)
67+
response.raise_for_status()
68+
69+
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
70+
return self.resource_
71+
72+
def save(self) -> Self:
73+
"""Save the current state of the resource to the api using the update method.
74+
75+
Raises:
76+
ValueError: If the resource has not been set.
77+
78+
Examples:
79+
contact.name = "New Name"
80+
contact.save()
81+
82+
"""
83+
if not self.resource_:
84+
raise ValueError("Unable to save resource that has not been set.")
85+
self.update(self.resource_.to_dict())
86+
return self
87+
88+
def delete(self) -> None:
89+
"""Delete the resource using `DELETE /endpoint/{resource_id}`.
90+
91+
Raises:
92+
HTTPStatusError: If the deletion fails.
93+
94+
Examples:
95+
contact.delete()
96+
"""
97+
response = self.mpt_client_.delete(self.resource_url)
98+
response.raise_for_status()
99+
100+
self.resource_ = None # noqa: WPS120
101+
102+
def _ensure_resource_is_fetched(self) -> None:
103+
if not self.resource_:
104+
self.fetch()

tests/http/collection/conftest.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import pytest
22

33
from mpt_api_client.http.collection import CollectionBaseClient
4-
from mpt_api_client.models import Collection, Resource
5-
6-
7-
class DummyResource(Resource):
8-
"""Dummy resource for testing."""
4+
from mpt_api_client.models import Collection
5+
from tests.http.conftest import DummyResource
96

107

118
class DummyCollectionClient(CollectionBaseClient[DummyResource]):

tests/http/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import pytest
22

33
from mpt_api_client.http.client import MPTClient
4+
from mpt_api_client.models import Resource
45

56
API_TOKEN = "test-token"
67
API_URL = "https://api.example.com"
78

89

10+
class DummyResource(Resource):
11+
"""Dummy resource for testing."""
12+
13+
914
@pytest.fixture
1015
def mpt_client():
1116
return MPTClient(base_url=API_URL, api_token=API_TOKEN)

tests/http/resource/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pytest
2+
3+
from mpt_api_client.http.client import MPTClient
4+
from mpt_api_client.http.resource import ResourceBaseClient
5+
from tests.http.conftest import DummyResource
6+
7+
8+
class DummyResourceClient(ResourceBaseClient[DummyResource]):
9+
_endpoint = "/api/v1/test-resource"
10+
_resource_class = DummyResource
11+
12+
13+
@pytest.fixture
14+
def api_url():
15+
return "https://api.example.com"
16+
17+
18+
@pytest.fixture
19+
def api_token():
20+
return "test-token"
21+
22+
23+
@pytest.fixture
24+
def mpt_client(api_url, api_token):
25+
return MPTClient(base_url=api_url, api_token=api_token)
26+
27+
28+
@pytest.fixture
29+
def resource_client(mpt_client):
30+
return DummyResourceClient(client=mpt_client, resource_id="RES-123")
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import httpx
2+
import pytest
3+
import respx
4+
5+
6+
def test_fetch_success(resource_client):
7+
expected_response = httpx.Response(
8+
httpx.codes.OK,
9+
json={"data": {"id": "RES-123", "name": "Test Resource", "status": "active"}},
10+
)
11+
12+
with respx.mock:
13+
mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
14+
return_value=expected_response
15+
)
16+
17+
resource = resource_client.fetch()
18+
19+
assert resource.to_dict() == {"id": "RES-123", "name": "Test Resource", "status": "active"}
20+
assert mock_route.called
21+
assert mock_route.call_count == 1
22+
assert resource_client.resource_ is not None
23+
24+
25+
def test_get_attribute(resource_client):
26+
expected_response = httpx.Response(
27+
httpx.codes.OK,
28+
json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}},
29+
)
30+
31+
with respx.mock:
32+
mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
33+
return_value=expected_response
34+
)
35+
36+
assert resource_client.id == "RES-123"
37+
assert resource_client.contact.name == "Albert"
38+
assert mock_route.call_count == 1
39+
40+
41+
def test_set_attribute(resource_client):
42+
expected_response = httpx.Response(
43+
httpx.codes.OK,
44+
json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}},
45+
)
46+
47+
with respx.mock:
48+
respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
49+
return_value=expected_response
50+
)
51+
52+
resource_client.status = "disabled"
53+
resource_client.contact.name = "Alice"
54+
55+
assert resource_client.status == "disabled"
56+
assert resource_client.contact.name == "Alice"
57+
58+
59+
def test_fetch_not_found(resource_client):
60+
error_response = httpx.Response(httpx.codes.NOT_FOUND, json={"error": "Resource not found"})
61+
62+
with respx.mock:
63+
respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
64+
return_value=error_response
65+
)
66+
67+
with pytest.raises(httpx.HTTPStatusError):
68+
resource_client.fetch()
69+
70+
71+
def test_fetch_server_error(resource_client):
72+
error_response = httpx.Response(
73+
httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal server error"}
74+
)
75+
76+
with respx.mock:
77+
respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
78+
return_value=error_response
79+
)
80+
81+
with pytest.raises(httpx.HTTPStatusError):
82+
resource_client.fetch()
83+
84+
85+
def test_fetch_with_special_characters_in_id(resource_client):
86+
expected_response = httpx.Response(
87+
httpx.codes.OK, json={"data": {"id": "RES-123", "name": "Special Resource"}}
88+
)
89+
90+
with respx.mock:
91+
mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
92+
return_value=expected_response
93+
)
94+
95+
resource = resource_client.fetch()
96+
97+
assert resource.to_dict() == {"id": "RES-123", "name": "Special Resource"}
98+
assert mock_route.called
99+
100+
101+
def test_fetch_verifies_correct_url_construction(resource_client):
102+
expected_response = httpx.Response(httpx.codes.OK, json={"data": {"id": "RES-123"}})
103+
104+
with respx.mock:
105+
mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
106+
return_value=expected_response
107+
)
108+
109+
resource_client.fetch()
110+
111+
request = mock_route.calls[0].request
112+
113+
assert request.method == "GET"
114+
assert str(request.url) == "https://api.example.com/api/v1/test-resource/RES-123"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import httpx
2+
import pytest
3+
import respx
4+
5+
6+
def test_update_resource_successfully(resource_client):
7+
update_data = {"name": "Updated Resource Name", "status": "modified", "version": 2}
8+
expected_response = httpx.Response(
9+
httpx.codes.OK,
10+
json={
11+
"data": {
12+
"id": "RES-123",
13+
"name": "Updated Resource Name",
14+
"status": "modified",
15+
"version": 2,
16+
}
17+
},
18+
)
19+
20+
with respx.mock:
21+
mock_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock(
22+
return_value=expected_response
23+
)
24+
25+
resource = resource_client.update(update_data)
26+
27+
assert resource.to_dict() == {
28+
"id": "RES-123",
29+
"name": "Updated Resource Name",
30+
"status": "modified",
31+
"version": 2,
32+
}
33+
assert mock_route.called
34+
assert mock_route.call_count == 1
35+
36+
37+
def test_save_resource_successfully(resource_client):
38+
fetch_response = httpx.Response(
39+
httpx.codes.OK,
40+
json={"data": {"id": "RES-123", "name": "Original Name", "status": "active"}},
41+
)
42+
save_response = httpx.Response(
43+
httpx.codes.OK,
44+
json={"data": {"id": "RES-123", "name": "Modified Name", "status": "active"}},
45+
)
46+
47+
with respx.mock:
48+
respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock(
49+
return_value=fetch_response
50+
)
51+
mock_put_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock(
52+
return_value=save_response
53+
)
54+
55+
resource_client.fetch()
56+
resource_client.name = "Modified Name"
57+
resource_client.save()
58+
59+
assert resource_client.resource_.to_dict() == {
60+
"id": "RES-123",
61+
"name": "Modified Name",
62+
"status": "active",
63+
}
64+
assert mock_put_route.called
65+
assert mock_put_route.call_count == 1
66+
67+
68+
def test_save_raises_error_when_resource_not_set(resource_client):
69+
with pytest.raises(ValueError, match="Unable to save resource that has not been set"):
70+
resource_client.save()
71+
72+
73+
def test_delete_resource_successfully(resource_client):
74+
delete_response = httpx.Response(httpx.codes.NO_CONTENT)
75+
76+
with respx.mock:
77+
mock_delete_route = respx.delete(
78+
"https://api.example.com/api/v1/test-resource/RES-123"
79+
).mock(return_value=delete_response)
80+
81+
resource_client.delete()
82+
83+
assert resource_client.resource_ is None
84+
assert mock_delete_route.called
85+
assert mock_delete_route.call_count == 1

0 commit comments

Comments
 (0)