Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mpt_api_client/http/async_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Re
HTTPStatusError: if the response status code is not 200.
"""
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
return await self.http_client.request("get", self.build_url(pagination_params))
return await self.http_client.request("get", self.build_path(pagination_params))

async def _resource_do_request( # noqa: WPS211
self,
Expand All @@ -112,7 +112,7 @@ async def _resource_do_request( # noqa: WPS211
Raises:
HTTPError: If the action fails.
"""
resource_url = urljoin(f"{self.endpoint}/", resource_id)
resource_url = urljoin(f"{self.path}/", resource_id)
url = urljoin(f"{resource_url}/", action) if action else resource_url
return await self.http_client.request(
method, url, json=json, query_params=query_params, headers=headers
Expand Down
102 changes: 12 additions & 90 deletions mpt_api_client/http/base_service.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import copy
from typing import Any, Self
from typing import Any

from mpt_api_client.http.mixins import QueryableMixin
from mpt_api_client.http.query_state import QueryState
from mpt_api_client.http.types import Response
from mpt_api_client.models import Collection, Meta
from mpt_api_client.models import Model as BaseModel
from mpt_api_client.rql import RQLQuery


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

_endpoint: str
Expand All @@ -18,107 +18,29 @@ def __init__(
self,
*,
http_client: Client,
query_rql: RQLQuery | None = None,
query_order_by: list[str] | None = None,
query_select: list[str] | None = None,
query_state: QueryState | None = None,
endpoint_params: dict[str, str] | None = None,
) -> None:
self.http_client = http_client
self.query_rql: RQLQuery | None = query_rql
self.query_order_by = query_order_by
self.query_select = query_select
self.query_state = query_state or QueryState()
self.endpoint_params = endpoint_params or {}

def clone(self) -> Self:
"""Create a copy of collection client for immutable operations.

Returns:
New collection client with same settings.
"""
return type(self)(
http_client=self.http_client,
query_rql=self.query_rql,
query_order_by=copy.copy(self.query_order_by) if self.query_order_by else None,
query_select=copy.copy(self.query_select) if self.query_select else None,
endpoint_params=self.endpoint_params,
)

@property
def endpoint(self) -> str:
def path(self) -> str:
"""Service endpoint URL."""
return self._endpoint.format(**self.endpoint_params)

def build_url(
def build_path(
self,
query_params: dict[str, Any] | None = None,
) -> str: # noqa: WPS210
) -> str:
"""Builds the endpoint URL with all the query parameters.

Returns:
Partial URL with query parameters.
Complete URL with query parameters.
"""
query_params = query_params or {}
if self.query_order_by:
query_params.update({"order": ",".join(self.query_order_by)})
if self.query_select:
query_params.update({"select": ",".join(self.query_select)})

query_parts = [
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
]

if self.query_rql:
query_parts.append(str(self.query_rql))

if query_parts:
query = "&".join(query_parts)
return f"{self.endpoint}?{query}"
return self.endpoint

def order_by(self, *fields: str) -> Self:
"""Returns new collection with ordering setup.

Returns:
New collection with ordering setup.

Raises:
ValueError: If ordering has already been set.
"""
if self.query_order_by is not None:
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
new_collection = self.clone()
new_collection.query_order_by = list(fields)
return new_collection

def filter(self, rql: RQLQuery) -> Self:
"""Creates a new collection with the filter added to the filter collection.

Returns:
New copy of the collection with the filter added.
"""
if self.query_rql:
rql = self.query_rql & rql
new_collection = self.clone()
new_collection.query_rql = rql
return new_collection

def select(self, *fields: str) -> Self:
"""Set select fields. Raises ValueError if select fields are already set.

Returns:
New copy of the collection with the select fields set.

Raises:
ValueError: If select fields are already set.
"""
if self.query_select is not None:
raise ValueError(
"Select fields are already set. Cannot set select fields multiple times."
)

new_client = self.clone()
new_client.query_select = list(fields)
return new_client
query = self.query_state.build(query_params)
return f"{self.path}?{query}" if query else self.path

@classmethod
def _create_collection(cls, response: Response) -> Collection[Model]:
Expand Down
84 changes: 79 additions & 5 deletions mpt_api_client/http/mixins.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import json
from typing import Self
from urllib.parse import urljoin

from mpt_api_client.http.query_state import QueryState
from mpt_api_client.http.types import FileTypes, Response
from mpt_api_client.models import FileModel, ResourceData
from mpt_api_client.rql import RQLQuery


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

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

Expand Down Expand Up @@ -82,7 +85,7 @@ def create(
"application/json",
)

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

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

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

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

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


Expand Down Expand Up @@ -173,7 +176,7 @@ async def create(
"application/json",
)

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

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

Expand Down Expand Up @@ -227,3 +230,74 @@ async def get(self, resource_id: str, select: list[str] | str | None = None) ->
if isinstance(select, list):
select = ",".join(select) if select else None
return await self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return]


class QueryableMixin:
"""Mixin providing query functionality for filtering, ordering, and selecting fields."""

def order_by(self, *fields: str) -> Self:
"""Returns new collection with ordering setup.

Returns:
New collection with ordering setup.

Raises:
ValueError: If ordering has already been set.
"""
if self.query_state.order_by is not None: # type: ignore[attr-defined]
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
return self._create_new_instance(
query_state=QueryState(
rql=self.query_state.filter, # type: ignore[attr-defined]
order_by=list(fields),
select=self.query_state.select, # type: ignore[attr-defined]
)
)

def filter(self, rql: RQLQuery) -> Self:
"""Creates a new collection with the filter added to the filter collection.

Returns:
New copy of the collection with the filter added.
"""
existing_filter = self.query_state.filter # type: ignore[attr-defined]
combined_filter = existing_filter & rql if existing_filter else rql
return self._create_new_instance(
QueryState(
rql=combined_filter,
order_by=self.query_state.order_by, # type: ignore[attr-defined]
select=self.query_state.select, # type: ignore[attr-defined]
)
)

def select(self, *fields: str) -> Self:
"""Set select fields. Raises ValueError if select fields are already set.

Returns:
New copy of the collection with the select fields set.

Raises:
ValueError: If select fields are already set.
"""
if self.query_state.select is not None: # type: ignore[attr-defined]
raise ValueError(
"Select fields are already set. Cannot set select fields multiple times."
)
return self._create_new_instance(
QueryState(
rql=self.query_state.filter, # type: ignore[attr-defined]
order_by=self.query_state.order_by, # type: ignore[attr-defined]
select=list(fields),
),
)

def _create_new_instance(
self,
query_state: QueryState,
) -> Self:
"""Create a new instance with the given parameters."""
return self.__class__(
http_client=self.http_client, # type: ignore[call-arg,attr-defined]
query_state=query_state,
endpoint_params=self.endpoint_params, # type: ignore[attr-defined]
)
72 changes: 72 additions & 0 deletions mpt_api_client/http/query_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from typing import Any

from mpt_api_client.rql import RQLQuery


class QueryState:
"""Stores and manages API query state for filtering, selecting, and ordering data.

This class maintains the current state of query parameters (filter, order_by, select)
and provides functionality to build query strings from that state. It's responsible
for both storing query configuration and constructing the appropriate query parameters
that modify the behavior and shape of data returned by the API.
"""

def __init__(
self,
rql: RQLQuery | None = None,
order_by: list[str] | None = None,
select: list[str] | None = None,
) -> None:
"""Initialize the query state with optional filter, ordering, and selection criteria.

Args:
rql: RQL query for filtering data.
order_by: List of fields to order by (prefix with '-' for descending).
select: List of fields to select in the response.
"""
self._filter = rql
self._order_by = order_by
self._select = select

@property
def filter(self) -> RQLQuery | None:
"""Get the current filter query."""
return self._filter

@property
def order_by(self) -> list[str] | None:
"""Get the current order by fields."""
return self._order_by

@property
def select(self) -> list[str] | None:
"""Get the current select fields."""
return self._select

def build(self, query_params: dict[str, Any] | None = None) -> str:
"""Build a query string from the current state and additional parameters.

Args:
query_params: Additional query parameters to include in the query string.

Returns:
Complete query string with all parameters, or empty string if no parameters.
"""
query_params = query_params or {}
if self._order_by:
query_params.update({"order": ",".join(self._order_by)})
if self._select:
query_params.update({"select": ",".join(self._select)})

query_parts = [
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
]

if self._filter:
query_parts.append(str(self._filter))

if query_parts:
query = "&".join(query_parts)
return f"{query}"
return ""
4 changes: 2 additions & 2 deletions mpt_api_client/http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response
HTTPStatusError: if the response status code is not 200.
"""
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
return self.http_client.request("get", self.build_url(pagination_params))
return self.http_client.request("get", self.build_path(pagination_params))

def _resource_do_request( # noqa: WPS211
self,
Expand All @@ -111,7 +111,7 @@ def _resource_do_request( # noqa: WPS211
Raises:
HTTPError: If the action fails.
"""
resource_url = urljoin(f"{self.endpoint}/", resource_id)
resource_url = urljoin(f"{self.path}/", resource_id)
url = urljoin(f"{resource_url}/", action) if action else resource_url
return self.http_client.request(
method, url, json=json, query_params=query_params, headers=headers
Expand Down
Loading