Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 13 additions & 9 deletions mpt_api_client/http/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import httpx

from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
from mpt_api_client.http.resource import ResourceBaseClient
from mpt_api_client.http.resource import AsyncResourceBaseClient, ResourceBaseClient
from mpt_api_client.models import Collection, Resource
from mpt_api_client.models.base import ResourceData
from mpt_api_client.rql.query_builder import RQLQuery


Expand Down Expand Up @@ -50,7 +51,7 @@ def clone(cls, collection_client: "CollectionMixin") -> Self:
)
return new_collection

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

Returns:
Expand All @@ -59,15 +60,18 @@ def build_url(self, query_params: dict[str, Any] | None = None) -> str:
query_params = query_params or {}
query_parts = [
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
] # noqa: WPS237
]
if self.query_order_by:
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
str_order_by = ",".join(self.query_order_by)
query_parts.append(f"order={str_order_by}")
if self.query_select:
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
str_query_select = ",".join(self.query_select)
query_parts.append(f"select={str_query_select}")
if self.query_rql:
query_parts.append(str(self.query_rql))
if query_parts:
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
query = "&".join(query_parts)
return f"{self._endpoint}?{query}"
return self._endpoint

def order_by(self, *fields: str) -> Self:
Expand Down Expand Up @@ -201,7 +205,7 @@ def get(self, resource_id: str) -> ResourceClient:
"""Get resource by resource_id."""
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id)

def create(self, resource_data: dict[str, Any]) -> ResourceModel:
def create(self, resource_data: ResourceData) -> ResourceModel:
"""Create a new resource using `POST /endpoint`.

Returns:
Expand Down Expand Up @@ -230,7 +234,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re

class AsyncCollectionClientBase[
ResourceModel: Resource,
ResourceClient: ResourceBaseClient[Resource],
ResourceClient: AsyncResourceBaseClient[Resource],
](ABC, CollectionMixin):
"""Immutable Base client for RESTful resource collections.

Expand Down Expand Up @@ -313,7 +317,7 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]:

async def get(self, resource_id: str) -> ResourceClient:
"""Get resource by resource_id."""
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type]
return self._resource_client_class(http_client=self.http_client, resource_id=resource_id)

async def create(self, resource_data: dict[str, Any]) -> ResourceModel:
"""Create a new resource using `POST /endpoint`.
Expand Down
193 changes: 161 additions & 32 deletions mpt_api_client/http/resource.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
from abc import ABC
from typing import Any, ClassVar, Self, override
from urllib.parse import urljoin

from httpx import Response

from mpt_api_client.http.client import HTTPClient
from mpt_api_client.http.client import HTTPClient, HTTPClientAsync
from mpt_api_client.models import Resource
from mpt_api_client.models.base import ResourceData, ResourceList


class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214
"""Client for RESTful resources."""
class ResourceMixin:
"""Mixin for resource clients."""

_endpoint: str
_resource_class: type[ResourceModel]
_resource_class: type[Any]
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}

def __init__(self, http_client: HTTPClient, resource_id: str) -> None:
self.http_client_ = http_client # noqa: WPS120
self.resource_id_ = resource_id # noqa: WPS120
self.resource_: Resource | None = None # noqa: WPS120
def __init__(
self, http_client: HTTPClient | HTTPClientAsync, resource_id: str, resource: Resource | None
) -> None:
self.http_client_ = http_client
self.resource_id_ = resource_id
self.resource_: Resource | None = resource

def __getattr__(self, attribute: str) -> Any:
"""Returns the resource data."""
self._ensure_resource_is_fetched()
self._assert_resource_is_set()
return self.resource_.__getattr__(attribute) # type: ignore[union-attr]

@property
Expand All @@ -34,9 +38,33 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None:
if attribute in self._safe_attributes:
object.__setattr__(self, attribute, attribute_value)
return
self._ensure_resource_is_fetched()
self._assert_resource_is_set()
self.resource_.__setattr__(attribute, attribute_value)

def _assert_resource_is_set(self) -> None:
if not self.resource_:
class_name = self._resource_class.__name__
raise RuntimeError(
f"Resource data not available. Call fetch() method first to retrieve"
f" the resource `{class_name}`"
)


class ResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin):
"""Client for RESTful resources."""

_endpoint: str
_resource_class: type[ResourceModel]
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}

def __init__(
self, http_client: HTTPClient, resource_id: str, resource: Resource | None = None
) -> None:
self.http_client_: HTTPClient = http_client # type: ignore[mutable-override]
ResourceMixin.__init__(
self, http_client=http_client, resource_id=resource_id, resource=resource
)

def fetch(self) -> ResourceModel:
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.

Expand All @@ -45,27 +73,74 @@ def fetch(self) -> ResourceModel:
Returns:
The fetched resource.
"""
response = self.do_action("GET")
response = self._do_action("GET")

self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
self.resource_ = self._resource_class.from_response(response)
return self.resource_

def resource_action(
def update(self, resource_data: ResourceData) -> ResourceModel:
"""Update a specific in the API and catches the result as a current resource.

Args:
resource_data: The updated resource data.

Returns:
The updated resource.

Examples:
updated_contact = contact.update({"name": "New Name"})


"""
response = self._do_action("PUT", json=resource_data)
self.resource_ = self._resource_class.from_response(response)
return self.resource_

def save(self) -> Self:
"""Save the current state of the resource to the api using the update method.

Raises:
ValueError: If the resource has not been set.

Examples:
contact.name = "New Name"
contact.save()

"""
self._assert_resource_is_set()
self.update(self.resource_.to_dict()) # type: ignore[union-attr]
return self

def delete(self) -> None:
"""Delete the resource using `DELETE /endpoint/{resource_id}`.

Raises:
HTTPStatusError: If the deletion fails.

Examples:
contact.delete()
"""
response = self._do_action("DELETE")
response.raise_for_status()

self.resource_ = None

def _resource_action(
self,
method: str = "GET",
url: str | None = None,
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
json: ResourceData | ResourceList | None = None,
) -> ResourceModel:
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
response = self.do_action(method, url, json=json)
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
response = self._do_action(method, url, json=json)
self.resource_ = self._resource_class.from_response(response)
return self.resource_

def do_action(
def _do_action(
self,
method: str = "GET",
url: str | None = None,
json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221
json: ResourceData | ResourceList | None = None,
) -> Response:
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.

Expand All @@ -82,7 +157,36 @@ def do_action(
response.raise_for_status()
return response

def update(self, resource_data: dict[str, Any]) -> ResourceModel:

class AsyncResourceBaseClient[ResourceModel: Resource](ABC, ResourceMixin):
"""Client for RESTful resources."""

_endpoint: str
_resource_class: type[ResourceModel]
_safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"}

def __init__(
self, http_client: HTTPClientAsync, resource_id: str, resource: Resource | None = None
) -> None:
self.http_client_: HTTPClientAsync = http_client # type: ignore[mutable-override]
ResourceMixin.__init__(
self, http_client=http_client, resource_id=resource_id, resource=resource
)

async def fetch(self) -> ResourceModel:
"""Fetch a specific resource using `GET /endpoint/{resource_id}`.

It fetches and caches the resource.

Returns:
The fetched resource.
"""
response = await self._do_action("GET")

self.resource_ = self._resource_class.from_response(response)
return self.resource_

async def update(self, resource_data: ResourceData) -> ResourceModel:
"""Update a specific in the API and catches the result as a current resource.

Args:
Expand All @@ -96,11 +200,9 @@ def update(self, resource_data: dict[str, Any]) -> ResourceModel:


"""
response = self.do_action("PUT", json=resource_data)
self.resource_ = self._resource_class.from_response(response) # noqa: WPS120
return self.resource_
return await self._resource_action("PUT", json=resource_data)

def save(self) -> Self:
async def save(self) -> Self:
"""Save the current state of the resource to the api using the update method.

Raises:
Expand All @@ -111,12 +213,11 @@ def save(self) -> Self:
contact.save()

"""
if not self.resource_:
raise ValueError("Unable to save resource that has not been set.")
self.update(self.resource_.to_dict())
self._assert_resource_is_set()
await self.update(self.resource_.to_dict()) # type: ignore[union-attr]
return self

def delete(self) -> None:
async def delete(self) -> None:
"""Delete the resource using `DELETE /endpoint/{resource_id}`.

Raises:
Expand All @@ -125,11 +226,39 @@ def delete(self) -> None:
Examples:
contact.delete()
"""
response = self.do_action("DELETE")
response = await self._do_action("DELETE")
response.raise_for_status()

self.resource_ = None # noqa: WPS120
self.resource_ = None

def _ensure_resource_is_fetched(self) -> None:
if not self.resource_:
self.fetch()
async def _resource_action(
self,
method: str = "GET",
url: str | None = None,
json: ResourceData | ResourceList | None = None,
) -> ResourceModel:
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`."""
response = await self._do_action(method, url, json=json)
self.resource_ = self._resource_class.from_response(response)
return self.resource_

async def _do_action(
self,
method: str = "GET",
url: str | None = None,
json: ResourceData | ResourceList | None = None,
) -> Response:
"""Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.

Args:
method: The HTTP method to use.
url: The action name to use.
json: The updated resource data.

Raises:
HTTPError: If the action fails.
"""
url = urljoin(self.resource_url, url) if url else self.resource_url
response = await self.http_client_.request(method, url, json=json)
response.raise_for_status()
return response
5 changes: 3 additions & 2 deletions mpt_api_client/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mpt_api_client.models.meta import Meta

ResourceData = dict[str, Any]
ResourceList = list[ResourceData]


class BaseResource(ABC):
Expand All @@ -28,7 +29,7 @@ def from_response(cls, response: Response) -> Self:
raise NotImplementedError

@abstractmethod
def to_dict(self) -> dict[str, Any]:
def to_dict(self) -> ResourceData:
"""Returns the resource as a dictionary."""
raise NotImplementedError

Expand All @@ -47,6 +48,6 @@ def from_response(cls, response: Response) -> Self:
raise NotImplementedError

@abstractmethod
def to_list(self) -> list[dict[str, Any]]:
def to_list(self) -> ResourceList:
"""Returns the collection as a list of dictionaries."""
raise NotImplementedError
8 changes: 4 additions & 4 deletions mpt_api_client/models/collection.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from collections.abc import Iterator
from typing import Any, ClassVar, Self, override
from typing import ClassVar, Self, override

from httpx import Response

from mpt_api_client.models.base import BaseCollection, ResourceData
from mpt_api_client.models.base import BaseCollection, ResourceList
from mpt_api_client.models.meta import Meta
from mpt_api_client.models.resource import Resource

Expand All @@ -15,7 +15,7 @@ class Collection[ResourceType](BaseCollection):
_resource_model: type[Resource] = Resource

def __init__(
self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None
self, collection_data: ResourceList | None = None, meta: Meta | None = None
) -> None:
self.meta = meta
collection_data = collection_data or []
Expand Down Expand Up @@ -50,5 +50,5 @@ def from_response(cls, response: Response) -> Self:
return cls(response_data, meta)

@override
def to_list(self) -> list[dict[str, Any]]:
def to_list(self) -> ResourceList:
return [resource.to_dict() for resource in self._resource_collection]
Loading