Skip to content

Commit 95d38d8

Browse files
committed
MPT-12327 Implement collection model
1 parent 50cf12c commit 95d38d8

File tree

11 files changed

+757
-13
lines changed

11 files changed

+757
-13
lines changed

mpt_api_client/http/client.py

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

35

@@ -7,18 +9,32 @@ 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+
self.api_token = api_token or os.getenv("MPT_TOKEN")
18+
if not self.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",
18-
"Authorization": f"Bearer {api_token}",
34+
"Authorization": f"Bearer {self.api_token}",
1935
}
2036
super().__init__(
21-
base_url=base_url,
37+
base_url=str(base_url),
2238
headers=base_headers,
2339
timeout=timeout,
2440
transport=httpx.HTTPTransport(retries=retries),

mpt_api_client/http/collection.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
from abc import ABC
5+
from collections.abc import Iterator
6+
from typing import Any, Self
7+
8+
import httpx
9+
10+
from mpt_api_client.http.client import MPTClient
11+
from mpt_api_client.models import Collection, Resource
12+
from mpt_api_client.rql.query_builder import RQLQuery
13+
14+
15+
class CollectionBaseClient[ResourceType: Resource](ABC): # noqa: WPS214
16+
"""Immutable Base client for RESTful resource collections.
17+
18+
Examples:
19+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
20+
active_orders = active_orders_cc.order_by("created").iterate()
21+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
22+
23+
new_order = order_collection.create(order_data)
24+
25+
"""
26+
27+
_endpoint: str
28+
_resource_class: type[Resource]
29+
_collection_class: type[Collection[Resource]]
30+
31+
def __init__(
32+
self,
33+
query_rql: RQLQuery | None = None,
34+
client: MPTClient | None = None,
35+
) -> None:
36+
self._client = client or MPTClient()
37+
self.query_rql: RQLQuery | None = query_rql
38+
self.query_order_by: list[str] | None = None
39+
self.query_select: list[str] | None = None
40+
41+
@classmethod
42+
def clone(cls, collection_client: CollectionBaseClient[ResourceType]) -> Self:
43+
"""Create a copy of collection client for immutable operations."""
44+
new_collection = cls(
45+
client=collection_client._client,
46+
query_rql=collection_client.query_rql,
47+
)
48+
new_collection.query_order_by = (
49+
copy.copy(collection_client.query_order_by)
50+
if collection_client.query_order_by
51+
else None
52+
)
53+
new_collection.query_select = (
54+
copy.copy(collection_client.query_select) if collection_client.query_select else None
55+
)
56+
return new_collection
57+
58+
def build_url(self, query_params: dict[str, Any] | None = None) -> str:
59+
"""Return the endpoint URL."""
60+
query_params = query_params or {}
61+
query_parts = [
62+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
63+
] # noqa: WPS237
64+
if self.query_order_by:
65+
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
66+
if self.query_select:
67+
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
68+
if self.query_rql:
69+
query_parts.append(str(self.query_rql))
70+
if query_parts:
71+
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
72+
return self._endpoint
73+
74+
def order_by(self, *fields: str) -> Self:
75+
"""Returns new collection with ordering setup.
76+
77+
Raises ValueError if ordering is already set.
78+
"""
79+
if self.query_order_by is not None:
80+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
81+
new_collection = self.clone(self)
82+
new_collection.query_order_by = list(fields)
83+
return new_collection
84+
85+
def filter(self, rql: RQLQuery) -> Self:
86+
"""Add filter using RQLQuery."""
87+
if self.query_rql:
88+
rql = self.query_rql & rql
89+
new_collection = self.clone(self)
90+
new_collection.query_rql = rql
91+
return new_collection
92+
93+
def select(self, *fields: str) -> Self:
94+
"""Set select fields. Raises ValueError if select fields are already set."""
95+
if self.query_select is not None:
96+
raise ValueError(
97+
"Select fields are already set. Cannot set select fields multiple times."
98+
)
99+
100+
new_client = self.clone(self)
101+
new_client.query_select = list(fields)
102+
return new_client
103+
104+
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceType]:
105+
"""Fetch one page of resources."""
106+
response = self._fetch_page_as_response(limit=limit, offset=offset)
107+
return Collection.from_response(response)
108+
109+
def fetch_one(self) -> ResourceType:
110+
"""Fetch one page, expect exactly one result."""
111+
response = self._fetch_page_as_response(limit=1, offset=0)
112+
resource_list: Collection[ResourceType] = Collection.from_response(response)
113+
total_records = len(resource_list)
114+
if resource_list.meta:
115+
total_records = resource_list.meta.pagination.total
116+
if total_records == 0:
117+
raise ValueError("Expected one result, but got zero results")
118+
if total_records > 1:
119+
raise ValueError(f"Expected one result, but got {total_records} results")
120+
121+
return resource_list[0]
122+
123+
def iterate(self) -> Iterator[ResourceType]:
124+
"""Iterate over all resources, yielding GenericResource objects."""
125+
offset = 0
126+
limit = 100 # Default page size
127+
128+
while True:
129+
response = self._fetch_page_as_response(limit=limit, offset=offset)
130+
items_collection: Collection[ResourceType] = Collection.from_response(response)
131+
yield from items_collection
132+
133+
if not items_collection.meta:
134+
break
135+
if not items_collection.meta.pagination.has_next():
136+
break
137+
offset = items_collection.meta.pagination.next_offset()
138+
139+
def create(self, resource_data: dict[str, Any]) -> ResourceType:
140+
"""Create a new resource using `POST /endpoint`."""
141+
response = self._client.post(self._endpoint, json=resource_data)
142+
response.raise_for_status()
143+
144+
return self._resource_class.from_response(response) # type: ignore[return-value]
145+
146+
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
147+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
148+
response = self._client.get(self.build_url(pagination_params))
149+
response.raise_for_status()
150+
151+
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
__test__ = False # Tell pytest this is not a test class
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(
20+
client=mpt_client,
21+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import pytest
2+
3+
from mpt_api_client.rql.query_builder import RQLQuery
4+
5+
6+
def test_filter(collection_client) -> None:
7+
filter_query = RQLQuery(status="active")
8+
9+
new_collection = collection_client.filter(filter_query)
10+
11+
assert new_collection != collection_client
12+
assert collection_client.query_rql is None
13+
assert new_collection.query_rql == filter_query
14+
15+
16+
def test_select(collection_client) -> None:
17+
new_collection = collection_client.select("agreement", "-product")
18+
19+
assert collection_client.query_select is None
20+
assert new_collection != collection_client
21+
assert new_collection.build_url() == "/api/v1/test?select=agreement,-product"
22+
23+
24+
def test_select_exception(collection_client) -> None:
25+
with pytest.raises(ValueError):
26+
collection_client.select("agreement").select("product")
27+
28+
29+
def test_order_by(collection_client):
30+
new_collection = collection_client.order_by("created", "-name")
31+
32+
assert new_collection != collection_client
33+
assert collection_client.query_order_by is None
34+
assert new_collection.build_url() == "/api/v1/test?order=created,-name"
35+
36+
37+
def test_order_by_exception(collection_client):
38+
with pytest.raises(
39+
ValueError, match=r"Ordering is already set. Cannot set ordering multiple times."
40+
):
41+
collection_client.order_by("created").order_by("name")
42+
43+
44+
def test_multiple_filters(collection_client) -> None:
45+
filter_query = RQLQuery(status="active")
46+
filter_query2 = RQLQuery(name="test")
47+
48+
url = collection_client.filter(filter_query).filter(filter_query2).build_url()
49+
50+
assert url == "/api/v1/test?and(eq(status,active),eq(name,test))"
51+
52+
53+
def test_url(collection_client) -> None:
54+
filter_query = RQLQuery(status="active")
55+
56+
custom_collection = (
57+
collection_client.filter(filter_query)
58+
.select("-audit", "product.agreements", "-product.agreements.product")
59+
.order_by("-created", "name")
60+
)
61+
url = custom_collection.build_url()
62+
assert custom_collection != collection_client
63+
assert url == (
64+
"/api/v1/test?order=-created,name"
65+
"&select=-audit,product.agreements,-product.agreements.product"
66+
"&eq(status,active)"
67+
)
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)