Skip to content

Commit fa0b2f7

Browse files
committed
MPT-12327 Implement collection client
1 parent 50cf12c commit fa0b2f7

File tree

11 files changed

+757
-12
lines changed

11 files changed

+757
-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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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: New collection client with same settings.
44+
"""
45+
new_collection = cls(
46+
client=collection_client.mpt_client,
47+
query_rql=collection_client.query_rql,
48+
)
49+
new_collection.query_order_by = (
50+
copy.copy(collection_client.query_order_by)
51+
if collection_client.query_order_by
52+
else None
53+
)
54+
new_collection.query_select = (
55+
copy.copy(collection_client.query_select) if collection_client.query_select else None
56+
)
57+
return new_collection
58+
59+
def build_url(self, query_params: dict[str, Any] | None = None) -> str:
60+
"""Return the endpoint URL."""
61+
query_params = query_params or {}
62+
query_parts = [
63+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
64+
] # noqa: WPS237
65+
if self.query_order_by:
66+
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
67+
if self.query_select:
68+
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
69+
if self.query_rql:
70+
query_parts.append(str(self.query_rql))
71+
if query_parts:
72+
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
73+
return self._endpoint
74+
75+
def order_by(self, *fields: str) -> Self:
76+
"""Returns new collection with ordering setup.
77+
78+
t. Returns:
79+
New collection with ordering setup.
80+
81+
Raises:
82+
ValueError: If ordering has already been set.
83+
84+
"""
85+
if self.query_order_by is not None:
86+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
87+
new_collection = self.clone(self)
88+
new_collection.query_order_by = list(fields)
89+
return new_collection
90+
91+
def filter(self, rql: RQLQuery) -> Self:
92+
"""Creates a new collection with the filter added to the filter collection.
93+
94+
Returns: New copy of the collection with the filter added.
95+
"""
96+
if self.query_rql:
97+
rql = self.query_rql & rql
98+
new_collection = self.clone(self)
99+
new_collection.query_rql = rql
100+
return new_collection
101+
102+
def select(self, *fields: str) -> Self:
103+
"""Set select fields. Raises ValueError if select fields are already set.
104+
105+
Returns: New copy of the collection with the select fields set.
106+
107+
Raises:
108+
ValueError: If select fields are already set.
109+
"""
110+
if self.query_select is not None:
111+
raise ValueError(
112+
"Select fields are already set. Cannot set select fields multiple times."
113+
)
114+
115+
new_client = self.clone(self)
116+
new_client.query_select = list(fields)
117+
return new_client
118+
119+
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceType]:
120+
"""Fetch one page of resources."""
121+
response = self._fetch_page_as_response(limit=limit, offset=offset)
122+
return Collection.from_response(response)
123+
124+
def fetch_one(self) -> ResourceType:
125+
"""Fetch one page, expect exactly one result."""
126+
response = self._fetch_page_as_response(limit=1, offset=0)
127+
resource_list: Collection[ResourceType] = Collection.from_response(response)
128+
total_records = len(resource_list)
129+
if resource_list.meta:
130+
total_records = resource_list.meta.pagination.total
131+
if total_records == 0:
132+
raise ValueError("Expected one result, but got zero results")
133+
if total_records > 1:
134+
raise ValueError(f"Expected one result, but got {total_records} results")
135+
136+
return resource_list[0]
137+
138+
def iterate(self) -> Iterator[ResourceType]:
139+
"""Iterate over all resources, yielding GenericResource objects."""
140+
offset = 0
141+
limit = 100 # Default page size
142+
143+
while True:
144+
response = self._fetch_page_as_response(limit=limit, offset=offset)
145+
items_collection: Collection[ResourceType] = Collection.from_response(response)
146+
yield from items_collection
147+
148+
if not items_collection.meta:
149+
break
150+
if not items_collection.meta.pagination.has_next():
151+
break
152+
offset = items_collection.meta.pagination.next_offset()
153+
154+
def create(self, resource_data: dict[str, Any]) -> ResourceType:
155+
"""Create a new resource using `POST /endpoint`.
156+
157+
Returns: New resource created.
158+
"""
159+
response = self.mpt_client.post(self._endpoint, json=resource_data)
160+
response.raise_for_status()
161+
162+
return self._resource_class.from_response(response) # type: ignore[return-value]
163+
164+
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
165+
"""Fetch one page of resources.
166+
167+
Returns: httpx.Response object.
168+
169+
Raises:
170+
HTTPStatusError: if the response status code is not 200.
171+
"""
172+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
173+
response = self.mpt_client.get(self.build_url(pagination_params))
174+
response.raise_for_status()
175+
176+
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)