Skip to content

Commit 6196a1c

Browse files
authored
MPT-12327 Implement collection client (#10)
2 parents 50cf12c + b5279fd commit 6196a1c

File tree

11 files changed

+780
-12
lines changed

11 files changed

+780
-12
lines changed

mpt_api_client/http/client.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import httpx
24

35

@@ -7,12 +9,26 @@ class MPTClient(httpx.Client):
79
def __init__(
810
self,
911
*,
10-
base_url: str,
11-
api_token: str,
12+
base_url: str | None = None,
13+
api_token: str | None = None,
1214
timeout: float = 5.0,
1315
retries: int = 0,
1416
):
15-
self.api_token = api_token
17+
api_token = api_token or os.getenv("MPT_TOKEN")
18+
if not api_token:
19+
raise ValueError(
20+
"API token is required. "
21+
"Set it up as env variable MPT_TOKEN or pass it as `api_token` "
22+
"argument to MPTClient."
23+
)
24+
25+
base_url = base_url or os.getenv("MPT_URL")
26+
if not base_url:
27+
raise ValueError(
28+
"Base URL is required. "
29+
"Set it up as env variable MPT_URL or pass it as `base_url` "
30+
"argument to MPTClient."
31+
)
1632
base_headers = {
1733
"User-Agent": "swo-marketplace-client/1.0",
1834
"Authorization": f"Bearer {api_token}",

mpt_api_client/http/collection.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import copy
2+
from abc import ABC
3+
from collections.abc import Iterator
4+
from typing import Any, Self
5+
6+
import httpx
7+
8+
from mpt_api_client.http.client import MPTClient
9+
from mpt_api_client.models import Collection, Resource
10+
from mpt_api_client.rql.query_builder import RQLQuery
11+
12+
13+
class CollectionBaseClient[ResourceType: Resource](ABC): # noqa: WPS214
14+
"""Immutable Base client for RESTful resource collections.
15+
16+
Examples:
17+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
18+
active_orders = active_orders_cc.order_by("created").iterate()
19+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
20+
21+
new_order = order_collection.create(order_data)
22+
23+
"""
24+
25+
_endpoint: str
26+
_resource_class: type[Resource]
27+
_collection_class: type[Collection[Resource]]
28+
29+
def __init__(
30+
self,
31+
query_rql: RQLQuery | None = None,
32+
client: MPTClient | None = None,
33+
) -> None:
34+
self.mpt_client = client or MPTClient()
35+
self.query_rql: RQLQuery | None = query_rql
36+
self.query_order_by: list[str] | None = None
37+
self.query_select: list[str] | None = None
38+
39+
@classmethod
40+
def clone(cls, collection_client: "CollectionBaseClient[ResourceType]") -> Self:
41+
"""Create a copy of collection client for immutable operations.
42+
43+
Returns:
44+
New collection client with same settings.
45+
"""
46+
new_collection = cls(
47+
client=collection_client.mpt_client,
48+
query_rql=collection_client.query_rql,
49+
)
50+
new_collection.query_order_by = (
51+
copy.copy(collection_client.query_order_by)
52+
if collection_client.query_order_by
53+
else None
54+
)
55+
new_collection.query_select = (
56+
copy.copy(collection_client.query_select) if collection_client.query_select else None
57+
)
58+
return new_collection
59+
60+
def build_url(self, query_params: dict[str, Any] | None = None) -> str:
61+
"""Builds the endpoint URL with all the query parameters.
62+
63+
Returns:
64+
Partial URL with query parameters.
65+
"""
66+
query_params = query_params or {}
67+
query_parts = [
68+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
69+
] # noqa: WPS237
70+
if self.query_order_by:
71+
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
72+
if self.query_select:
73+
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
74+
if self.query_rql:
75+
query_parts.append(str(self.query_rql))
76+
if query_parts:
77+
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
78+
return self._endpoint
79+
80+
def order_by(self, *fields: str) -> Self:
81+
"""Returns new collection with ordering setup.
82+
83+
Returns:
84+
New collection with ordering setup.
85+
86+
Raises:
87+
ValueError: If ordering has already been set.
88+
"""
89+
if self.query_order_by is not None:
90+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
91+
new_collection = self.clone(self)
92+
new_collection.query_order_by = list(fields)
93+
return new_collection
94+
95+
def filter(self, rql: RQLQuery) -> Self:
96+
"""Creates a new collection with the filter added to the filter collection.
97+
98+
Returns:
99+
New copy of the collection with the filter added.
100+
"""
101+
if self.query_rql:
102+
rql = self.query_rql & rql
103+
new_collection = self.clone(self)
104+
new_collection.query_rql = rql
105+
return new_collection
106+
107+
def select(self, *fields: str) -> Self:
108+
"""Set select fields. Raises ValueError if select fields are already set.
109+
110+
Returns:
111+
New copy of the collection with the select fields set.
112+
113+
Raises:
114+
ValueError: If select fields are already set.
115+
"""
116+
if self.query_select is not None:
117+
raise ValueError(
118+
"Select fields are already set. Cannot set select fields multiple times."
119+
)
120+
121+
new_client = self.clone(self)
122+
new_client.query_select = list(fields)
123+
return new_client
124+
125+
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceType]:
126+
"""Fetch one page of resources.
127+
128+
Returns:
129+
Collection of resources.
130+
"""
131+
response = self._fetch_page_as_response(limit=limit, offset=offset)
132+
return Collection.from_response(response)
133+
134+
def fetch_one(self) -> ResourceType:
135+
"""Fetch one page, expect exactly one result.
136+
137+
Returns:
138+
One resource.
139+
140+
Raises:
141+
ValueError: If the total matching records are not exactly one.
142+
"""
143+
response = self._fetch_page_as_response(limit=1, offset=0)
144+
resource_list: Collection[ResourceType] = Collection.from_response(response)
145+
total_records = len(resource_list)
146+
if resource_list.meta:
147+
total_records = resource_list.meta.pagination.total
148+
if total_records == 0:
149+
raise ValueError("Expected one result, but got zero results")
150+
if total_records > 1:
151+
raise ValueError(f"Expected one result, but got {total_records} results")
152+
153+
return resource_list[0]
154+
155+
def iterate(self) -> Iterator[ResourceType]:
156+
"""Iterate over all resources, yielding GenericResource objects.
157+
158+
Returns:
159+
Iterator of resources.
160+
"""
161+
offset = 0
162+
limit = 100 # Default page size
163+
164+
while True:
165+
response = self._fetch_page_as_response(limit=limit, offset=offset)
166+
items_collection: Collection[ResourceType] = Collection.from_response(response)
167+
yield from items_collection
168+
169+
if not items_collection.meta:
170+
break
171+
if not items_collection.meta.pagination.has_next():
172+
break
173+
offset = items_collection.meta.pagination.next_offset()
174+
175+
def create(self, resource_data: dict[str, Any]) -> ResourceType:
176+
"""Create a new resource using `POST /endpoint`.
177+
178+
Returns:
179+
New resource created.
180+
"""
181+
response = self.mpt_client.post(self._endpoint, json=resource_data)
182+
response.raise_for_status()
183+
184+
return self._resource_class.from_response(response) # type: ignore[return-value]
185+
186+
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
187+
"""Fetch one page of resources.
188+
189+
Returns:
190+
httpx.Response object.
191+
192+
Raises:
193+
HTTPStatusError: if the response status code is not 200.
194+
"""
195+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
196+
response = self.mpt_client.get(self.build_url(pagination_params))
197+
response.raise_for_status()
198+
199+
return response

setup.cfg

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ per-file-ignores =
3737
WPS110
3838
# Found `noqa` comments overuse
3939
WPS402
40+
tests/http/collection/test_collection_client_iterate.py:
41+
# Found too many module members
42+
WPS202
43+
tests/http/collection/test_collection_client_fetch.py:
44+
# Found too many module members
45+
WPS202
46+
# Found magic number
47+
WPS432
4048
tests/*:
4149
# Allow magic strings
4250
WPS432

tests/http/collection/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
3+
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."""
9+
10+
11+
class DummyCollectionClient(CollectionBaseClient[DummyResource]):
12+
_endpoint = "/api/v1/test"
13+
_resource_class = DummyResource
14+
_collection_class = Collection[DummyResource]
15+
16+
17+
@pytest.fixture
18+
def collection_client(mpt_client):
19+
return DummyCollectionClient(client=mpt_client)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
3+
from mpt_api_client.rql.query_builder import RQLQuery
4+
5+
6+
def test_filter(collection_client):
7+
filter_query = RQLQuery(status="active")
8+
9+
new_collection = collection_client.filter(filter_query)
10+
11+
assert collection_client.query_rql is None
12+
assert new_collection != collection_client
13+
assert new_collection.query_rql == filter_query
14+
15+
16+
def test_multiple_filters(collection_client) -> None:
17+
filter_query = RQLQuery(status="active")
18+
filter_query2 = RQLQuery(name="test")
19+
20+
new_collection = collection_client.filter(filter_query).filter(filter_query2)
21+
22+
assert collection_client.query_rql is None
23+
assert new_collection.query_rql == filter_query & filter_query2
24+
25+
26+
def test_select(collection_client) -> None:
27+
new_collection = collection_client.select("agreement", "-product")
28+
29+
assert collection_client.query_select is None
30+
assert new_collection != collection_client
31+
assert new_collection.query_select == ["agreement", "-product"]
32+
33+
34+
def test_select_exception(collection_client) -> None:
35+
with pytest.raises(ValueError):
36+
collection_client.select("agreement").select("product")
37+
38+
39+
def test_order_by(collection_client):
40+
new_collection = collection_client.order_by("created", "-name")
41+
42+
assert collection_client.query_order_by is None
43+
assert new_collection != collection_client
44+
assert new_collection.query_order_by == ["created", "-name"]
45+
46+
47+
def test_order_by_exception(collection_client):
48+
with pytest.raises(
49+
ValueError, match=r"Ordering is already set. Cannot set ordering multiple times."
50+
):
51+
collection_client.order_by("created").order_by("name")
52+
53+
54+
def test_url(collection_client) -> None:
55+
filter_query = RQLQuery(status="active")
56+
custom_collection = (
57+
collection_client.filter(filter_query)
58+
.select("-audit", "product.agreements", "-product.agreements.product")
59+
.order_by("-created", "name")
60+
)
61+
62+
url = custom_collection.build_url()
63+
64+
assert custom_collection != collection_client
65+
assert url == (
66+
"/api/v1/test?order=-created,name"
67+
"&select=-audit,product.agreements,-product.agreements.product"
68+
"&eq(status,active)"
69+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import json
2+
3+
import httpx
4+
import respx
5+
6+
7+
def test_create_resource(collection_client): # noqa: WPS210
8+
resource_data = {"name": "Test Resource", "status": "active"}
9+
new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"}
10+
create_response = httpx.Response(201, json={"data": new_resource_data})
11+
12+
with respx.mock:
13+
mock_route = respx.post("https://api.example.com/api/v1/test").mock(
14+
return_value=create_response
15+
)
16+
17+
created_resource = collection_client.create(resource_data)
18+
19+
assert created_resource.to_dict() == new_resource_data
20+
assert mock_route.call_count == 1
21+
request = mock_route.calls[0].request
22+
assert request.method == "POST"
23+
assert request.url == "https://api.example.com/api/v1/test"
24+
assert json.loads(request.content.decode()) == resource_data

0 commit comments

Comments
 (0)