Skip to content

Commit db7cb11

Browse files
authored
MPT-14532 Cleanup base service (#95)
2 parents a61214a + 4cab9ee commit db7cb11

38 files changed

Lines changed: 429 additions & 283 deletions

mpt_api_client/http/async_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Re
8585
HTTPStatusError: if the response status code is not 200.
8686
"""
8787
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
88-
return await self.http_client.request("get", self.build_url(pagination_params))
88+
return await self.http_client.request("get", self.build_path(pagination_params))
8989

9090
async def _resource_do_request( # noqa: WPS211
9191
self,
@@ -112,7 +112,7 @@ async def _resource_do_request( # noqa: WPS211
112112
Raises:
113113
HTTPError: If the action fails.
114114
"""
115-
resource_url = urljoin(f"{self.endpoint}/", resource_id)
115+
resource_url = urljoin(f"{self.path}/", resource_id)
116116
url = urljoin(f"{resource_url}/", action) if action else resource_url
117117
return await self.http_client.request(
118118
method, url, json=json, query_params=query_params, headers=headers

mpt_api_client/http/base_service.py

Lines changed: 12 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import copy
2-
from typing import Any, Self
1+
from typing import Any
32

3+
from mpt_api_client.http.mixins import QueryableMixin
4+
from mpt_api_client.http.query_state import QueryState
45
from mpt_api_client.http.types import Response
56
from mpt_api_client.models import Collection, Meta
67
from mpt_api_client.models import Model as BaseModel
7-
from mpt_api_client.rql import RQLQuery
88

99

10-
class ServiceBase[Client, Model: BaseModel]: # noqa: WPS214
10+
class ServiceBase[Client, Model: BaseModel](QueryableMixin): # noqa: WPS214
1111
"""Service base with agnostic HTTP client."""
1212

1313
_endpoint: str
@@ -18,107 +18,29 @@ def __init__(
1818
self,
1919
*,
2020
http_client: Client,
21-
query_rql: RQLQuery | None = None,
22-
query_order_by: list[str] | None = None,
23-
query_select: list[str] | None = None,
21+
query_state: QueryState | None = None,
2422
endpoint_params: dict[str, str] | None = None,
2523
) -> None:
2624
self.http_client = http_client
27-
self.query_rql: RQLQuery | None = query_rql
28-
self.query_order_by = query_order_by
29-
self.query_select = query_select
25+
self.query_state = query_state or QueryState()
3026
self.endpoint_params = endpoint_params or {}
3127

32-
def clone(self) -> Self:
33-
"""Create a copy of collection client for immutable operations.
34-
35-
Returns:
36-
New collection client with same settings.
37-
"""
38-
return type(self)(
39-
http_client=self.http_client,
40-
query_rql=self.query_rql,
41-
query_order_by=copy.copy(self.query_order_by) if self.query_order_by else None,
42-
query_select=copy.copy(self.query_select) if self.query_select else None,
43-
endpoint_params=self.endpoint_params,
44-
)
45-
4628
@property
47-
def endpoint(self) -> str:
29+
def path(self) -> str:
4830
"""Service endpoint URL."""
4931
return self._endpoint.format(**self.endpoint_params)
5032

51-
def build_url(
33+
def build_path(
5234
self,
5335
query_params: dict[str, Any] | None = None,
54-
) -> str: # noqa: WPS210
36+
) -> str:
5537
"""Builds the endpoint URL with all the query parameters.
5638
5739
Returns:
58-
Partial URL with query parameters.
40+
Complete URL with query parameters.
5941
"""
60-
query_params = query_params or {}
61-
if self.query_order_by:
62-
query_params.update({"order": ",".join(self.query_order_by)})
63-
if self.query_select:
64-
query_params.update({"select": ",".join(self.query_select)})
65-
66-
query_parts = [
67-
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
68-
]
69-
70-
if self.query_rql:
71-
query_parts.append(str(self.query_rql))
72-
73-
if query_parts:
74-
query = "&".join(query_parts)
75-
return f"{self.endpoint}?{query}"
76-
return self.endpoint
77-
78-
def order_by(self, *fields: str) -> Self:
79-
"""Returns new collection with ordering setup.
80-
81-
Returns:
82-
New collection with ordering setup.
83-
84-
Raises:
85-
ValueError: If ordering has already been set.
86-
"""
87-
if self.query_order_by is not None:
88-
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
89-
new_collection = self.clone()
90-
new_collection.query_order_by = list(fields)
91-
return new_collection
92-
93-
def filter(self, rql: RQLQuery) -> Self:
94-
"""Creates a new collection with the filter added to the filter collection.
95-
96-
Returns:
97-
New copy of the collection with the filter added.
98-
"""
99-
if self.query_rql:
100-
rql = self.query_rql & rql
101-
new_collection = self.clone()
102-
new_collection.query_rql = rql
103-
return new_collection
104-
105-
def select(self, *fields: str) -> Self:
106-
"""Set select fields. Raises ValueError if select fields are already set.
107-
108-
Returns:
109-
New copy of the collection with the select fields set.
110-
111-
Raises:
112-
ValueError: If select fields are already set.
113-
"""
114-
if self.query_select is not None:
115-
raise ValueError(
116-
"Select fields are already set. Cannot set select fields multiple times."
117-
)
118-
119-
new_client = self.clone()
120-
new_client.query_select = list(fields)
121-
return new_client
42+
query = self.query_state.build(query_params)
43+
return f"{self.path}?{query}" if query else self.path
12244

12345
@classmethod
12446
def _create_collection(cls, response: Response) -> Collection[Model]:

mpt_api_client/http/mixins.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import json
2+
from typing import Self
23
from urllib.parse import urljoin
34

5+
from mpt_api_client.http.query_state import QueryState
46
from mpt_api_client.http.types import FileTypes, Response
57
from mpt_api_client.models import FileModel, ResourceData
8+
from mpt_api_client.rql import RQLQuery
69

710

811
def _json_to_file_payload(resource_data: ResourceData) -> bytes:
@@ -20,7 +23,7 @@ def create(self, resource_data: ResourceData) -> Model:
2023
Returns:
2124
New resource created.
2225
"""
23-
response = self.http_client.request("post", self.endpoint, json=resource_data) # type: ignore[attr-defined]
26+
response = self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined]
2427

2528
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
2629

@@ -82,7 +85,7 @@ def create(
8285
"application/json",
8386
)
8487

85-
response = self.http_client.request("post", self.endpoint, files=files) # type: ignore[attr-defined]
88+
response = self.http_client.request("post", self.path, files=files) # type: ignore[attr-defined]
8689

8790
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
8891

@@ -110,7 +113,7 @@ async def create(self, resource_data: ResourceData) -> Model:
110113
Returns:
111114
New resource created.
112115
"""
113-
response = await self.http_client.request("post", self.endpoint, json=resource_data) # type: ignore[attr-defined]
116+
response = await self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined]
114117

115118
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
116119

@@ -124,7 +127,7 @@ async def delete(self, resource_id: str) -> None:
124127
Args:
125128
resource_id: Resource ID.
126129
"""
127-
url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined]
130+
url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined]
128131
await self.http_client.request("delete", url) # type: ignore[attr-defined]
129132

130133

@@ -173,7 +176,7 @@ async def create(
173176
"application/json",
174177
)
175178

176-
response = await self.http_client.request("post", self.endpoint, files=files) # type: ignore[attr-defined]
179+
response = await self.http_client.request("post", self.path, files=files) # type: ignore[attr-defined]
177180

178181
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
179182

@@ -227,3 +230,74 @@ async def get(self, resource_id: str, select: list[str] | str | None = None) ->
227230
if isinstance(select, list):
228231
select = ",".join(select) if select else None
229232
return await self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return]
233+
234+
235+
class QueryableMixin:
236+
"""Mixin providing query functionality for filtering, ordering, and selecting fields."""
237+
238+
def order_by(self, *fields: str) -> Self:
239+
"""Returns new collection with ordering setup.
240+
241+
Returns:
242+
New collection with ordering setup.
243+
244+
Raises:
245+
ValueError: If ordering has already been set.
246+
"""
247+
if self.query_state.order_by is not None: # type: ignore[attr-defined]
248+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
249+
return self._create_new_instance(
250+
query_state=QueryState(
251+
rql=self.query_state.filter, # type: ignore[attr-defined]
252+
order_by=list(fields),
253+
select=self.query_state.select, # type: ignore[attr-defined]
254+
)
255+
)
256+
257+
def filter(self, rql: RQLQuery) -> Self:
258+
"""Creates a new collection with the filter added to the filter collection.
259+
260+
Returns:
261+
New copy of the collection with the filter added.
262+
"""
263+
existing_filter = self.query_state.filter # type: ignore[attr-defined]
264+
combined_filter = existing_filter & rql if existing_filter else rql
265+
return self._create_new_instance(
266+
QueryState(
267+
rql=combined_filter,
268+
order_by=self.query_state.order_by, # type: ignore[attr-defined]
269+
select=self.query_state.select, # type: ignore[attr-defined]
270+
)
271+
)
272+
273+
def select(self, *fields: str) -> Self:
274+
"""Set select fields. Raises ValueError if select fields are already set.
275+
276+
Returns:
277+
New copy of the collection with the select fields set.
278+
279+
Raises:
280+
ValueError: If select fields are already set.
281+
"""
282+
if self.query_state.select is not None: # type: ignore[attr-defined]
283+
raise ValueError(
284+
"Select fields are already set. Cannot set select fields multiple times."
285+
)
286+
return self._create_new_instance(
287+
QueryState(
288+
rql=self.query_state.filter, # type: ignore[attr-defined]
289+
order_by=self.query_state.order_by, # type: ignore[attr-defined]
290+
select=list(fields),
291+
),
292+
)
293+
294+
def _create_new_instance(
295+
self,
296+
query_state: QueryState,
297+
) -> Self:
298+
"""Create a new instance with the given parameters."""
299+
return self.__class__(
300+
http_client=self.http_client, # type: ignore[call-arg,attr-defined]
301+
query_state=query_state,
302+
endpoint_params=self.endpoint_params, # type: ignore[attr-defined]
303+
)

mpt_api_client/http/query_state.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import Any
2+
3+
from mpt_api_client.rql import RQLQuery
4+
5+
6+
class QueryState:
7+
"""Stores and manages API query state for filtering, selecting, and ordering data.
8+
9+
This class maintains the current state of query parameters (filter, order_by, select)
10+
and provides functionality to build query strings from that state. It's responsible
11+
for both storing query configuration and constructing the appropriate query parameters
12+
that modify the behavior and shape of data returned by the API.
13+
"""
14+
15+
def __init__(
16+
self,
17+
rql: RQLQuery | None = None,
18+
order_by: list[str] | None = None,
19+
select: list[str] | None = None,
20+
) -> None:
21+
"""Initialize the query state with optional filter, ordering, and selection criteria.
22+
23+
Args:
24+
rql: RQL query for filtering data.
25+
order_by: List of fields to order by (prefix with '-' for descending).
26+
select: List of fields to select in the response.
27+
"""
28+
self._filter = rql
29+
self._order_by = order_by
30+
self._select = select
31+
32+
@property
33+
def filter(self) -> RQLQuery | None:
34+
"""Get the current filter query."""
35+
return self._filter
36+
37+
@property
38+
def order_by(self) -> list[str] | None:
39+
"""Get the current order by fields."""
40+
return self._order_by
41+
42+
@property
43+
def select(self) -> list[str] | None:
44+
"""Get the current select fields."""
45+
return self._select
46+
47+
def build(self, query_params: dict[str, Any] | None = None) -> str:
48+
"""Build a query string from the current state and additional parameters.
49+
50+
Args:
51+
query_params: Additional query parameters to include in the query string.
52+
53+
Returns:
54+
Complete query string with all parameters, or empty string if no parameters.
55+
"""
56+
query_params = query_params or {}
57+
if self._order_by:
58+
query_params.update({"order": ",".join(self._order_by)})
59+
if self._select:
60+
query_params.update({"select": ",".join(self._select)})
61+
62+
query_parts = [
63+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
64+
]
65+
66+
if self._filter:
67+
query_parts.append(str(self._filter))
68+
69+
if query_parts:
70+
query = "&".join(query_parts)
71+
return f"{query}"
72+
return ""

mpt_api_client/http/service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response
8484
HTTPStatusError: if the response status code is not 200.
8585
"""
8686
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
87-
return self.http_client.request("get", self.build_url(pagination_params))
87+
return self.http_client.request("get", self.build_path(pagination_params))
8888

8989
def _resource_do_request( # noqa: WPS211
9090
self,
@@ -111,7 +111,7 @@ def _resource_do_request( # noqa: WPS211
111111
Raises:
112112
HTTPError: If the action fails.
113113
"""
114-
resource_url = urljoin(f"{self.endpoint}/", resource_id)
114+
resource_url = urljoin(f"{self.path}/", resource_id)
115115
url = urljoin(f"{resource_url}/", action) if action else resource_url
116116
return self.http_client.request(
117117
method, url, json=json, query_params=query_params, headers=headers

0 commit comments

Comments
 (0)