Skip to content

Commit 79cf50f

Browse files
committed
MPT-12358 Collection client async
1 parent 3cde683 commit 79cf50f

8 files changed

+569
-7
lines changed

mpt_api_client/http/collection.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import copy
22
from abc import ABC
3-
from collections.abc import Iterator
3+
from collections.abc import AsyncIterator, Iterator
44
from typing import Any, Self
55

66
import httpx
@@ -226,3 +226,117 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
226226
response.raise_for_status()
227227

228228
return response
229+
230+
231+
class AsyncCollectionClientBase[
232+
ResourceModel: Resource,
233+
ResourceClient: ResourceBaseClient[Resource],
234+
](ABC, CollectionMixin):
235+
"""Immutable Base client for RESTful resource collections.
236+
237+
Examples:
238+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
239+
active_orders = active_orders_cc.order_by("created").iterate()
240+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
241+
242+
new_order = order_collection.create(order_data)
243+
244+
"""
245+
246+
_resource_class: type[ResourceModel]
247+
_resource_client_class: type[ResourceClient]
248+
_collection_class: type[Collection[ResourceModel]]
249+
250+
def __init__(
251+
self,
252+
query_rql: RQLQuery | None = None,
253+
http_client: HTTPClientAsync | None = None,
254+
) -> None:
255+
self.http_client: HTTPClientAsync = http_client or HTTPClientAsync() # type: ignore[mutable-override]
256+
CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql)
257+
258+
async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]:
259+
"""Fetch one page of resources.
260+
261+
Returns:
262+
Collection of resources.
263+
"""
264+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
265+
return Collection.from_response(response)
266+
267+
async def fetch_one(self) -> ResourceModel:
268+
"""Fetch one page, expect exactly one result.
269+
270+
Returns:
271+
One resource.
272+
273+
Raises:
274+
ValueError: If the total matching records are not exactly one.
275+
"""
276+
response = await self._fetch_page_as_response(limit=1, offset=0)
277+
resource_list: Collection[ResourceModel] = Collection.from_response(response)
278+
total_records = len(resource_list)
279+
if resource_list.meta:
280+
total_records = resource_list.meta.pagination.total
281+
if total_records == 0:
282+
raise ValueError("Expected one result, but got zero results")
283+
if total_records > 1:
284+
raise ValueError(f"Expected one result, but got {total_records} results")
285+
286+
return resource_list[0]
287+
288+
async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]:
289+
"""Iterate over all resources, yielding GenericResource objects.
290+
291+
Args:
292+
batch_size: Number of resources to fetch per request
293+
294+
Returns:
295+
Iterator of resources.
296+
"""
297+
offset = 0
298+
limit = batch_size # Default page size
299+
300+
while True:
301+
response = await self._fetch_page_as_response(limit=limit, offset=offset)
302+
items_collection: Collection[ResourceModel] = self._collection_class.from_response(
303+
response
304+
)
305+
for resource in items_collection:
306+
yield resource
307+
308+
if not items_collection.meta:
309+
break
310+
if not items_collection.meta.pagination.has_next():
311+
break
312+
offset = items_collection.meta.pagination.next_offset()
313+
314+
async def get(self, resource_id: str) -> ResourceClient:
315+
"""Get resource by resource_id."""
316+
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type]
317+
318+
async def create(self, resource_data: dict[str, Any]) -> ResourceModel:
319+
"""Create a new resource using `POST /endpoint`.
320+
321+
Returns:
322+
New resource created.
323+
"""
324+
response = await self.http_client.post(self._endpoint, json=resource_data)
325+
response.raise_for_status()
326+
327+
return self._resource_class.from_response(response)
328+
329+
async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
330+
"""Fetch one page of resources.
331+
332+
Returns:
333+
httpx.Response object.
334+
335+
Raises:
336+
HTTPStatusError: if the response status code is not 200.
337+
"""
338+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
339+
response = await self.http_client.get(self.build_url(pagination_params))
340+
response.raise_for_status()
341+
342+
return response
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import json
2+
3+
import httpx
4+
import pytest
5+
import respx
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_create_resource(async_collection_client): # noqa: WPS210
10+
resource_data = {"name": "Test Resource", "status": "active"}
11+
new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"}
12+
create_response = httpx.Response(201, json={"data": new_resource_data})
13+
14+
with respx.mock:
15+
mock_route = respx.post("https://api.example.com/api/v1/test").mock(
16+
return_value=create_response
17+
)
18+
19+
created_resource = await async_collection_client.create(resource_data)
20+
21+
assert created_resource.to_dict() == new_resource_data
22+
assert mock_route.call_count == 1
23+
request = mock_route.calls[0].request
24+
assert request.method == "POST"
25+
assert request.url == "https://api.example.com/api/v1/test"
26+
assert json.loads(request.content.decode()) == resource_data
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import httpx
2+
import pytest
3+
import respx
4+
5+
from mpt_api_client.rql import RQLQuery
6+
7+
8+
@pytest.fixture
9+
def list_response():
10+
return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]})
11+
12+
13+
@pytest.fixture
14+
def single_result_response():
15+
return httpx.Response(
16+
httpx.codes.OK,
17+
json={
18+
"data": [{"id": "ID-1", "name": "Test Resource"}],
19+
"$meta": {"pagination": {"total": 1, "offset": 0, "limit": 1}},
20+
},
21+
)
22+
23+
24+
@pytest.fixture
25+
def no_results_response():
26+
return httpx.Response(
27+
httpx.codes.OK,
28+
json={"data": [], "$meta": {"pagination": {"total": 0, "offset": 0, "limit": 1}}},
29+
)
30+
31+
32+
@pytest.fixture
33+
def multiple_results_response():
34+
return httpx.Response(
35+
200,
36+
json={
37+
"data": [{"id": "ID-1", "name": "Resource 1"}, {"id": "ID-2", "name": "Resource 2"}],
38+
"$meta": {"pagination": {"total": 2, "offset": 0, "limit": 1}},
39+
},
40+
)
41+
42+
43+
@pytest.fixture
44+
def no_meta_response():
45+
return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]})
46+
47+
48+
@pytest.fixture
49+
def filter_status_active():
50+
return RQLQuery(status="active")
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_fetch_one_success(async_collection_client, single_result_response):
55+
with respx.mock:
56+
mock_route = respx.get("https://api.example.com/api/v1/test").mock(
57+
return_value=single_result_response
58+
)
59+
60+
resource = await async_collection_client.fetch_one()
61+
62+
assert resource.id == "ID-1"
63+
assert resource.name == "Test Resource"
64+
assert mock_route.called
65+
66+
first_request = mock_route.calls[0].request
67+
assert "limit=1" in str(first_request.url)
68+
assert "offset=0" in str(first_request.url)
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_fetch_one_no_results(async_collection_client, no_results_response):
73+
with respx.mock:
74+
respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response)
75+
76+
with pytest.raises(ValueError, match="Expected one result, but got zero results"):
77+
await async_collection_client.fetch_one()
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_fetch_one_multiple_results(async_collection_client, multiple_results_response):
82+
with respx.mock:
83+
respx.get("https://api.example.com/api/v1/test").mock(
84+
return_value=multiple_results_response
85+
)
86+
87+
with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"):
88+
await async_collection_client.fetch_one()
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_fetch_one_with_filters(
93+
async_collection_client, single_result_response, filter_status_active
94+
):
95+
filtered_collection = (
96+
async_collection_client.filter(filter_status_active)
97+
.select("id", "name")
98+
.order_by("created")
99+
)
100+
101+
with respx.mock:
102+
mock_route = respx.get("https://api.example.com/api/v1/test").mock(
103+
return_value=single_result_response
104+
)
105+
resource = await filtered_collection.fetch_one()
106+
107+
assert resource.id == "ID-1"
108+
assert mock_route.called
109+
110+
first_request = mock_route.calls[0].request
111+
assert first_request.method == "GET"
112+
assert first_request.url == (
113+
"https://api.example.com/api/v1/test"
114+
"?limit=1&offset=0&order=created"
115+
"&select=id,name&eq(status,active)"
116+
)
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_fetch_page_with_filter(
121+
async_collection_client, list_response, filter_status_active
122+
) -> None:
123+
custom_collection = (
124+
async_collection_client.filter(filter_status_active)
125+
.select("-audit", "product.agreements", "-product.agreements.product")
126+
.order_by("-created", "name")
127+
)
128+
129+
expected_url = (
130+
"https://api.example.com/api/v1/test?limit=10&offset=5"
131+
"&order=-created,name"
132+
"&select=-audit,product.agreements,-product.agreements.product"
133+
"&eq(status,active)"
134+
)
135+
with respx.mock:
136+
mock_route = respx.get("https://api.example.com/api/v1/test").mock(
137+
return_value=list_response
138+
)
139+
collection_results = await custom_collection.fetch_page(limit=10, offset=5)
140+
141+
assert collection_results.to_list() == [{"id": "ID-1"}]
142+
assert mock_route.called
143+
assert mock_route.call_count == 1
144+
request = mock_route.calls[0].request
145+
assert request.method == "GET"
146+
assert request.url == expected_url
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import pytest
2+
3+
from mpt_api_client.http.client import HTTPClientAsync
4+
from mpt_api_client.rql.query_builder import RQLQuery
5+
from tests.http.conftest import DummyAsyncCollectionClientBase
6+
7+
8+
@pytest.fixture
9+
def mock_mpt_client_async(api_url, api_token):
10+
return HTTPClientAsync(base_url=api_url, api_token=api_token)
11+
12+
13+
@pytest.fixture
14+
def sample_rql_query():
15+
return RQLQuery(status="active")
16+
17+
18+
def test_init_defaults(async_collection_client):
19+
assert async_collection_client.query_rql is None
20+
assert async_collection_client.query_order_by is None
21+
assert async_collection_client.query_select is None
22+
assert async_collection_client.build_url() == "/api/v1/test"
23+
24+
25+
def test_init_with_filter(http_client_async, sample_rql_query):
26+
collection_client = DummyAsyncCollectionClientBase(
27+
http_client=http_client_async,
28+
query_rql=sample_rql_query,
29+
)
30+
31+
assert collection_client.query_rql == sample_rql_query
32+
assert collection_client.query_order_by is None
33+
assert collection_client.query_select is None
34+
assert collection_client.build_url() == "/api/v1/test?eq(status,active)"

0 commit comments

Comments
 (0)