diff --git a/mpt_api_client/__init__.py b/mpt_api_client/__init__.py index 2afb150..5f3cf00 100644 --- a/mpt_api_client/__init__.py +++ b/mpt_api_client/__init__.py @@ -1,4 +1,4 @@ -from mpt_api_client.mptclient import MPTClient +from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient from mpt_api_client.rql import RQLQuery -__all__ = ["MPTClient", "RQLQuery"] # noqa: WPS410 +__all__ = ["AsyncMPTClient", "MPTClient", "RQLQuery"] # noqa: WPS410 diff --git a/mpt_api_client/http/__init__.py b/mpt_api_client/http/__init__.py new file mode 100644 index 0000000..e5affc2 --- /dev/null +++ b/mpt_api_client/http/__init__.py @@ -0,0 +1,6 @@ +from mpt_api_client.http.async_client import AsyncHTTPClient +from mpt_api_client.http.async_service import AsyncService +from mpt_api_client.http.client import HTTPClient +from mpt_api_client.http.service import Service + +__all__ = ["AsyncHTTPClient", "AsyncService", "HTTPClient", "Service"] # noqa: WPS410 diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py new file mode 100644 index 0000000..8f11307 --- /dev/null +++ b/mpt_api_client/http/async_client.py @@ -0,0 +1,41 @@ +import os + +from httpx import AsyncClient, AsyncHTTPTransport + + +class AsyncHTTPClient(AsyncClient): + """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" + + def __init__( + self, + *, + base_url: str | None = None, + api_token: str | None = None, + timeout: float = 5.0, + retries: int = 0, + ): + api_token = api_token or os.getenv("MPT_TOKEN") + if not api_token: + raise ValueError( + "API token is required. " + "Set it up as env variable MPT_TOKEN or pass it as `api_token` " + "argument to MPTClient." + ) + + base_url = base_url or os.getenv("MPT_URL") + if not base_url: + raise ValueError( + "Base URL is required. " + "Set it up as env variable MPT_URL or pass it as `base_url` " + "argument to MPTClient." + ) + base_headers = { + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": f"Bearer {api_token}", + } + super().__init__( + base_url=base_url, + headers=base_headers, + timeout=timeout, + transport=AsyncHTTPTransport(retries=retries), + ) diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py new file mode 100644 index 0000000..d23aeaf --- /dev/null +++ b/mpt_api_client/http/async_service.py @@ -0,0 +1,151 @@ +from collections.abc import AsyncIterator +from urllib.parse import urljoin + +import httpx + +from mpt_api_client.http.async_client import AsyncHTTPClient +from mpt_api_client.http.base_service import ServiceBase +from mpt_api_client.models import Collection, ResourceData +from mpt_api_client.models import Model as BaseModel +from mpt_api_client.models.collection import ResourceList + + +class AsyncService[Model: BaseModel](ServiceBase[AsyncHTTPClient, Model]): # noqa: WPS214 + """Immutable Service for RESTful resource collections. + + Examples: + active_orders_cc = order_collection.filter(RQLQuery(status="active")) + active_orders = active_orders_cc.order_by("created").iterate() + product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() + + new_order = order_collection.create(order_data) + + """ + + async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + """Fetch one page of resources.""" + response = await self._fetch_page_as_response(limit=limit, offset=offset) + return self._create_collection(response) + + async def fetch_one(self) -> Model: + """Fetch one resource, expect exactly one result. + + Returns: + One resource. + + Raises: + ValueError: If the total matching records are not exactly one. + """ + response = await self._fetch_page_as_response(limit=1, offset=0) + resource_list = self._create_collection(response) + total_records = len(resource_list) + if resource_list.meta: + total_records = resource_list.meta.pagination.total + if total_records == 0: + raise ValueError("Expected one result, but got zero results") + if total_records > 1: + raise ValueError(f"Expected one result, but got {total_records} results") + + return resource_list[0] + + async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: + """Iterate over all resources, yielding GenericResource objects. + + Args: + batch_size: Number of resources to fetch per request + + Returns: + Iterator of resources. + """ + offset = 0 + limit = batch_size # Default page size + + while True: + response = await self._fetch_page_as_response(limit=limit, offset=offset) + items_collection = self._create_collection(response) + for resource in items_collection: + yield resource + + if not items_collection.meta: + break + if not items_collection.meta.pagination.has_next(): + break + offset = items_collection.meta.pagination.next_offset() + + async def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = await self.http_client.post(self._endpoint, json=resource_data) + response.raise_for_status() + + return self._model_class.from_response(response) + + async def get(self, resource_id: str) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" + return await self._resource_action(resource_id=resource_id) + + async def update(self, resource_id: str, resource_data: ResourceData) -> Model: + """Update a resource using `PUT /endpoint/{resource_id}`.""" + return await self._resource_action(resource_id, "PUT", json=resource_data) + + async def delete(self, resource_id: str) -> None: + """Delete resource using `DELETE /endpoint/{resource_id}`.""" + url = urljoin(f"{self._endpoint}/", resource_id) + response = await self.http_client.delete(url) + response.raise_for_status() + + async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: + """Fetch one page of resources. + + Returns: + httpx.Response object. + + Raises: + HTTPStatusError: if the response status code is not 200. + """ + pagination_params: dict[str, int] = {"limit": limit, "offset": offset} + response = await self.http_client.get(self.build_url(pagination_params)) + response.raise_for_status() + + return response + + async def _resource_do_request( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> httpx.Response: + """Perform an action on a specific resource using. + + Request with action: `HTTP_METHOD /endpoint/{resource_id}/{action}`. + Request without action: `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + + Raises: + HTTPError: If the action fails. + """ + resource_url = urljoin(f"{self._endpoint}/", resource_id) + url = urljoin(f"{resource_url}/", action) if action else resource_url + response = await self.http_client.request(method, url, json=json) + response.raise_for_status() + return response + + async def _resource_action( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> Model: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" + response = await self._resource_do_request(resource_id, method, action, json=json) + return self._model_class.from_response(response) diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py new file mode 100644 index 0000000..daa90ae --- /dev/null +++ b/mpt_api_client/http/base_service.py @@ -0,0 +1,120 @@ +import copy +from typing import Any, Self + +import httpx + +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]: + """Service base with agnostic HTTP client.""" + + _endpoint: str + _model_class: type[Model] + _collection_key = "data" + + def __init__( + self, + *, + http_client: Client, + query_rql: RQLQuery | None = None, + query_order_by: list[str] | None = None, + query_select: list[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 + + 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, + ) + + def build_url(self, query_params: dict[str, Any] | None = None) -> str: # noqa: WPS210 + """Builds the endpoint URL with all the query parameters. + + Returns: + Partial URL with query parameters. + """ + query_params = query_params or {} + query_parts = [ + f"{param_key}={param_value}" for param_key, param_value in query_params.items() + ] + if self.query_order_by: + str_order_by = ",".join(self.query_order_by) + query_parts.append(f"order={str_order_by}") + if self.query_select: + 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: + 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 + + def _create_collection(self, response: httpx.Response) -> Collection[Model]: + meta = Meta.from_response(response) + return Collection( + resources=[ + self._model_class.new(resource, meta) + for resource in response.json().get(self._collection_key) + ], + meta=meta, + ) diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 234b70d..6208915 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -1,6 +1,6 @@ import os -from httpx import AsyncClient, AsyncHTTPTransport, Client, HTTPTransport +from httpx import Client, HTTPTransport class HTTPClient(Client): @@ -33,49 +33,9 @@ def __init__( "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", } - Client.__init__( - self, + super().__init__( base_url=base_url, headers=base_headers, timeout=timeout, transport=HTTPTransport(retries=retries), ) - - -class HTTPClientAsync(AsyncClient): - """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" - - def __init__( - self, - *, - base_url: str | None = None, - api_token: str | None = None, - timeout: float = 5.0, - retries: int = 0, - ): - api_token = api_token or os.getenv("MPT_TOKEN") - if not api_token: - raise ValueError( - "API token is required. " - "Set it up as env variable MPT_TOKEN or pass it as `api_token` " - "argument to MPTClient." - ) - - base_url = base_url or os.getenv("MPT_URL") - if not base_url: - raise ValueError( - "Base URL is required. " - "Set it up as env variable MPT_URL or pass it as `base_url` " - "argument to MPTClient." - ) - base_headers = { - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": f"Bearer {api_token}", - } - AsyncClient.__init__( - self, - base_url=base_url, - headers=base_headers, - timeout=timeout, - transport=AsyncHTTPTransport(retries=retries), - ) diff --git a/mpt_api_client/http/collection.py b/mpt_api_client/http/collection.py deleted file mode 100644 index 17a6f12..0000000 --- a/mpt_api_client/http/collection.py +++ /dev/null @@ -1,342 +0,0 @@ -import copy -from abc import ABC -from collections.abc import AsyncIterator, Iterator -from typing import Any, Self - -import httpx - -from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection, Resource -from mpt_api_client.rql.query_builder import RQLQuery - - -class CollectionMixin: - """Mixin for collection clients.""" - - _endpoint: str - _resource_class: type[Any] - _resource_client_class: type[Any] - _collection_class: type[Collection[Any]] - - def __init__( - self, - http_client: HTTPClient | HTTPClientAsync, - query_rql: RQLQuery | None = None, - ) -> None: - self.http_client = http_client - self.query_rql: RQLQuery | None = query_rql - self.query_order_by: list[str] | None = None - self.query_select: list[str] | None = None - - @classmethod - def clone(cls, collection_client: "CollectionMixin") -> Self: - """Create a copy of collection client for immutable operations. - - Returns: - New collection client with same settings. - """ - new_collection = cls( - http_client=collection_client.http_client, - query_rql=collection_client.query_rql, - ) - new_collection.query_order_by = ( - copy.copy(collection_client.query_order_by) - if collection_client.query_order_by - else None - ) - new_collection.query_select = ( - copy.copy(collection_client.query_select) if collection_client.query_select else None - ) - return new_collection - - def build_url(self, query_params: dict[str, Any] | None = None) -> str: - """Builds the endpoint URL with all the query parameters. - - Returns: - Partial URL with query parameters. - """ - 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 - if self.query_select: - query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237 - if self.query_rql: - query_parts.append(str(self.query_rql)) - if query_parts: - return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237 - 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(self) - 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(self) - 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(self) - new_client.query_select = list(fields) - return new_client - - -class CollectionClientBase[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214 - ABC, CollectionMixin -): - """Immutable Base client for RESTful resource collections. - - Examples: - active_orders_cc = order_collection.filter(RQLQuery(status="active")) - active_orders = active_orders_cc.order_by("created").iterate() - product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() - - new_order = order_collection.create(order_data) - - """ - - _resource_class: type[ResourceModel] - _resource_client_class: type[ResourceClient] - _collection_class: type[Collection[ResourceModel]] - - def __init__( - self, - query_rql: RQLQuery | None = None, - http_client: HTTPClient | None = None, - ) -> None: - self.http_client: HTTPClient = http_client or HTTPClient() # type: ignore[mutable-override] - CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) - - def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: - """Fetch one page of resources. - - Returns: - Collection of resources. - """ - response = self._fetch_page_as_response(limit=limit, offset=offset) - return Collection.from_response(response) - - def fetch_one(self) -> ResourceModel: - """Fetch one page, expect exactly one result. - - Returns: - One resource. - - Raises: - ValueError: If the total matching records are not exactly one. - """ - response = self._fetch_page_as_response(limit=1, offset=0) - resource_list: Collection[ResourceModel] = Collection.from_response(response) - total_records = len(resource_list) - if resource_list.meta: - total_records = resource_list.meta.pagination.total - if total_records == 0: - raise ValueError("Expected one result, but got zero results") - if total_records > 1: - raise ValueError(f"Expected one result, but got {total_records} results") - - return resource_list[0] - - def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]: - """Iterate over all resources, yielding GenericResource objects. - - Args: - batch_size: Number of resources to fetch per request - - Returns: - Iterator of resources. - """ - offset = 0 - limit = batch_size # Default page size - - while True: - response = self._fetch_page_as_response(limit=limit, offset=offset) - items_collection: Collection[ResourceModel] = self._collection_class.from_response( - response - ) - yield from items_collection - - if not items_collection.meta: - break - if not items_collection.meta.pagination.has_next(): - break - offset = items_collection.meta.pagination.next_offset() - - 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: - """Create a new resource using `POST /endpoint`. - - Returns: - New resource created. - """ - response = self.http_client.post(self._endpoint, json=resource_data) - response.raise_for_status() - - return self._resource_class.from_response(response) - - def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: - """Fetch one page of resources. - - Returns: - httpx.Response object. - - Raises: - HTTPStatusError: if the response status code is not 200. - """ - pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - response = self.http_client.get(self.build_url(pagination_params)) - response.raise_for_status() - - return response - - -class AsyncCollectionClientBase[ - ResourceModel: Resource, - ResourceClient: ResourceBaseClient[Resource], -](ABC, CollectionMixin): - """Immutable Base client for RESTful resource collections. - - Examples: - active_orders_cc = order_collection.filter(RQLQuery(status="active")) - active_orders = active_orders_cc.order_by("created").iterate() - product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() - - new_order = order_collection.create(order_data) - - """ - - _resource_class: type[ResourceModel] - _resource_client_class: type[ResourceClient] - _collection_class: type[Collection[ResourceModel]] - - def __init__( - self, - query_rql: RQLQuery | None = None, - http_client: HTTPClientAsync | None = None, - ) -> None: - self.http_client: HTTPClientAsync = http_client or HTTPClientAsync() # type: ignore[mutable-override] - CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) - - async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: - """Fetch one page of resources. - - Returns: - Collection of resources. - """ - response = await self._fetch_page_as_response(limit=limit, offset=offset) - return Collection.from_response(response) - - async def fetch_one(self) -> ResourceModel: - """Fetch one page, expect exactly one result. - - Returns: - One resource. - - Raises: - ValueError: If the total matching records are not exactly one. - """ - response = await self._fetch_page_as_response(limit=1, offset=0) - resource_list: Collection[ResourceModel] = Collection.from_response(response) - total_records = len(resource_list) - if resource_list.meta: - total_records = resource_list.meta.pagination.total - if total_records == 0: - raise ValueError("Expected one result, but got zero results") - if total_records > 1: - raise ValueError(f"Expected one result, but got {total_records} results") - - return resource_list[0] - - async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]: - """Iterate over all resources, yielding GenericResource objects. - - Args: - batch_size: Number of resources to fetch per request - - Returns: - Iterator of resources. - """ - offset = 0 - limit = batch_size # Default page size - - while True: - response = await self._fetch_page_as_response(limit=limit, offset=offset) - items_collection: Collection[ResourceModel] = self._collection_class.from_response( - response - ) - for resource in items_collection: - yield resource - - if not items_collection.meta: - break - if not items_collection.meta.pagination.has_next(): - break - offset = items_collection.meta.pagination.next_offset() - - 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] - - async def create(self, resource_data: dict[str, Any]) -> ResourceModel: - """Create a new resource using `POST /endpoint`. - - Returns: - New resource created. - """ - response = await self.http_client.post(self._endpoint, json=resource_data) - response.raise_for_status() - - return self._resource_class.from_response(response) - - async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: - """Fetch one page of resources. - - Returns: - httpx.Response object. - - Raises: - HTTPStatusError: if the response status code is not 200. - """ - pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - response = await self.http_client.get(self.build_url(pagination_params)) - response.raise_for_status() - - return response diff --git a/mpt_api_client/http/resource.py b/mpt_api_client/http/resource.py deleted file mode 100644 index bc5fb48..0000000 --- a/mpt_api_client/http/resource.py +++ /dev/null @@ -1,135 +0,0 @@ -from abc import ABC -from typing import Any, ClassVar, Self, override - -from httpx import Response - -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.models import Resource - - -class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214 - """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) -> None: - self.http_client_ = http_client # noqa: WPS120 - self.resource_id_ = resource_id # noqa: WPS120 - self.resource_: Resource | None = None # noqa: WPS120 - - def __getattr__(self, attribute: str) -> Any: - """Returns the resource data.""" - self._ensure_resource_is_fetched() - return self.resource_.__getattr__(attribute) # type: ignore[union-attr] - - @property - def resource_url(self) -> str: - """Returns the resource URL.""" - return f"{self._endpoint}/{self.resource_id_}" - - @override - 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.resource_.__setattr__(attribute, attribute_value) - - def fetch(self) -> ResourceModel: - """Fetch a specific resource using `GET /endpoint/{resource_id}`. - - It fetches and caches the resource. - - Returns: - The fetched resource. - """ - response = self.do_action("GET") - - self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 - return self.resource_ - - def resource_action( - self, - method: str = "GET", - url: str | None = None, - json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221 - ) -> 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 - return self.resource_ - - def do_action( - self, - method: str = "GET", - url: str | None = None, - json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221 - ) -> 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 = f"{self.resource_url}/{url}" if url else self.resource_url - response = self.http_client_.request(method, url, json=json) - response.raise_for_status() - return response - - def update(self, resource_data: dict[str, Any]) -> 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) # noqa: WPS120 - 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() - - """ - if not self.resource_: - raise ValueError("Unable to save resource that has not been set.") - self.update(self.resource_.to_dict()) - 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 # noqa: WPS120 - - def _ensure_resource_is_fetched(self) -> None: - if not self.resource_: - self.fetch() diff --git a/mpt_api_client/http/service.py b/mpt_api_client/http/service.py new file mode 100644 index 0000000..a2ab693 --- /dev/null +++ b/mpt_api_client/http/service.py @@ -0,0 +1,153 @@ +from collections.abc import Iterator +from urllib.parse import urljoin + +import httpx + +from mpt_api_client.http.base_service import ServiceBase +from mpt_api_client.http.client import HTTPClient +from mpt_api_client.models import Collection, ResourceData +from mpt_api_client.models import Model as BaseModel +from mpt_api_client.models.collection import ResourceList + + +class Service[Model: BaseModel](ServiceBase[HTTPClient, Model]): + """Immutable service for RESTful resource collections. + + Examples: + active_orders_cc = order_collection.filter(RQLQuery(status="active")) + active_orders = active_orders_cc.order_by("created").iterate() + product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate() + + new_order = order_collection.create(order_data) + + """ + + def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + """Fetch one page of resources. + + Returns: + Collection of resources. + """ + response = self._fetch_page_as_response(limit=limit, offset=offset) + return self._create_collection(response) + + def fetch_one(self) -> Model: + """Fetch one resource, expect exactly one result. + + Returns: + One resource. + + Raises: + ValueError: If the total matching records are not exactly one. + """ + response = self._fetch_page_as_response(limit=1, offset=0) + resource_list = self._create_collection(response) + total_records = len(resource_list) + if resource_list.meta: + total_records = resource_list.meta.pagination.total + if total_records == 0: + raise ValueError("Expected one result, but got zero results") + if total_records > 1: + raise ValueError(f"Expected one result, but got {total_records} results") + + return resource_list[0] + + def iterate(self, batch_size: int = 100) -> Iterator[Model]: + """Iterate over all resources, yielding GenericResource objects. + + Args: + batch_size: Number of resources to fetch per request + + Returns: + Iterator of resources. + """ + offset = 0 + limit = batch_size # Default page size + + while True: + response = self._fetch_page_as_response(limit=limit, offset=offset) + items_collection = self._create_collection(response) + yield from items_collection + + if not items_collection.meta: + break + if not items_collection.meta.pagination.has_next(): + break + offset = items_collection.meta.pagination.next_offset() + + def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = self.http_client.post(self._endpoint, json=resource_data) + response.raise_for_status() + + return self._model_class.from_response(response) + + def get(self, resource_id: str) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" + return self._resource_action(resource_id=resource_id) + + def update(self, resource_id: str, resource_data: ResourceData) -> Model: + """Update a resource using `PUT /endpoint/{resource_id}`.""" + return self._resource_action(resource_id, "PUT", json=resource_data) + + def delete(self, resource_id: str) -> None: + """Delete the resoruce using `DELETE /endpoint/{resource_id}`.""" + response = self._resource_do_request(resource_id, "DELETE") + response.raise_for_status() + + def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: + """Fetch one page of resources. + + Returns: + httpx.Response object. + + Raises: + HTTPStatusError: if the response status code is not 200. + """ + pagination_params: dict[str, int] = {"limit": limit, "offset": offset} + response = self.http_client.get(self.build_url(pagination_params)) + response.raise_for_status() + + return response + + def _resource_do_request( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> httpx.Response: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + + Returns: + HTTP response object. + + Raises: + HTTPError: If the action fails. + """ + resource_url = urljoin(f"{self._endpoint}/", resource_id) + url = urljoin(f"{resource_url}/", action) if action else resource_url + response = self.http_client.request(method, url, json=json) + response.raise_for_status() + return response + + def _resource_action( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> Model: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" + response = self._resource_do_request(resource_id, method, action, json=json) + return self._model_class.from_response(response) diff --git a/mpt_api_client/models/__init__.py b/mpt_api_client/models/__init__.py index 0e02657..6b52626 100644 --- a/mpt_api_client/models/__init__.py +++ b/mpt_api_client/models/__init__.py @@ -1,5 +1,5 @@ from mpt_api_client.models.collection import Collection from mpt_api_client.models.meta import Meta, Pagination -from mpt_api_client.models.resource import Resource +from mpt_api_client.models.model import Model, ResourceData -__all__ = ["Collection", "Meta", "Pagination", "Resource"] # noqa: WPS410 +__all__ = ["Collection", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410 diff --git a/mpt_api_client/models/base.py b/mpt_api_client/models/base.py deleted file mode 100644 index af22be3..0000000 --- a/mpt_api_client/models/base.py +++ /dev/null @@ -1,52 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Self - -from httpx import Response - -from mpt_api_client.models.meta import Meta - -ResourceData = dict[str, Any] - - -class BaseResource(ABC): - """Provides a base resource to interact with api data using fluent interfaces.""" - - @classmethod - @abstractmethod - def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: - """Creates a new resource from ResourceData and Meta.""" - raise NotImplementedError - - @classmethod - @abstractmethod - def from_response(cls, response: Response) -> Self: - """Creates a collection from a response. - - Args: - response: The httpx response object. - """ - raise NotImplementedError - - @abstractmethod - def to_dict(self) -> dict[str, Any]: - """Returns the resource as a dictionary.""" - raise NotImplementedError - - -class BaseCollection(ABC): - """Provides a base collection to interact with api collection data using fluent interfaces.""" - - @classmethod - @abstractmethod - def from_response(cls, response: Response) -> Self: - """Creates a collection from a response. - - Args: - response: The httpx response object. - """ - raise NotImplementedError - - @abstractmethod - def to_list(self) -> list[dict[str, Any]]: - """Returns the collection as a list of dictionaries.""" - raise NotImplementedError diff --git a/mpt_api_client/models/collection.py b/mpt_api_client/models/collection.py index 1bb1d04..8c8c51f 100644 --- a/mpt_api_client/models/collection.py +++ b/mpt_api_client/models/collection.py @@ -1,54 +1,34 @@ from collections.abc import Iterator -from typing import Any, ClassVar, Self, override -from httpx import Response - -from mpt_api_client.models.base import BaseCollection, ResourceData from mpt_api_client.models.meta import Meta -from mpt_api_client.models.resource import Resource +from mpt_api_client.models.model import Model, ResourceData +ResourceList = list[ResourceData] -class Collection[ResourceType](BaseCollection): - """Provides a base collection to interact with api collection data using fluent interfaces.""" - _data_key: ClassVar[str] = "data" - _resource_model: type[Resource] = Resource +class Collection[ItemType: Model]: + """Provides a collection to interact with api collection data using fluent interfaces.""" - def __init__( - self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None - ) -> None: + def __init__(self, resources: list[ItemType] | None = None, meta: Meta | None = None) -> None: self.meta = meta - collection_data = collection_data or [] - self._resource_collection = [ - self._resource_model.new(resource_data, meta) for resource_data in collection_data - ] + self.resources = resources or [] - def __getitem__(self, index: int) -> ResourceType: + def __getitem__(self, index: int) -> ItemType: """Returns the collection item at the given index.""" - return self._resource_collection[index] # type: ignore[return-value] + return self.resources[index] - def __iter__(self) -> Iterator[ResourceType]: + def __iter__(self) -> Iterator[ItemType]: """Make GenericCollection iterable.""" - return iter(self._resource_collection) # type: ignore[arg-type] + return iter(self.resources) def __len__(self) -> int: """Return the number of items in the collection.""" - return len(self._resource_collection) + return len(self.resources) def __bool__(self) -> bool: """Returns True if collection has items.""" - return len(self._resource_collection) > 0 - - @override - @classmethod - def from_response(cls, response: Response) -> Self: - response_data = response.json().get(cls._data_key) - meta = Meta.from_response(response) - if not isinstance(response_data, list): - raise TypeError(f"Response `{cls._data_key}` must be a list for collection endpoints.") - - return cls(response_data, meta) + return len(self.resources) > 0 - @override - def to_list(self) -> list[dict[str, Any]]: - return [resource.to_dict() for resource in self._resource_collection] + def to_list(self) -> ResourceList: + """Returns the collection as a list of dictionaries.""" + return [resource.to_dict() for resource in self.resources] diff --git a/mpt_api_client/models/resource.py b/mpt_api_client/models/model.py similarity index 86% rename from mpt_api_client/models/resource.py rename to mpt_api_client/models/model.py index 89a02ef..758809b 100644 --- a/mpt_api_client/models/resource.py +++ b/mpt_api_client/models/model.py @@ -3,11 +3,12 @@ from box import Box from httpx import Response -from mpt_api_client.models.base import BaseResource, ResourceData from mpt_api_client.models.meta import Meta +ResourceData = dict[str, Any] -class Resource(BaseResource): + +class Model: """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None @@ -18,8 +19,8 @@ def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) @classmethod - @override def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: + """Creates a new resource from ResourceData and Meta.""" return cls(resource_data, meta) def __getattr__(self, attribute: str) -> Box | Any: @@ -35,8 +36,12 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] @classmethod - @override def from_response(cls, response: Response) -> Self: + """Creates a collection from a response. + + Args: + response: The httpx response object. + """ response_data = response.json() if isinstance(response_data, dict): response_data.pop("$meta", None) @@ -47,6 +52,6 @@ def from_response(cls, response: Response) -> Self: meta = Meta.from_response(response) return cls.new(response_data, meta) - @override def to_dict(self) -> dict[str, Any]: + """Returns the resource as a dictionary.""" return self._resource_data.to_dict() diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py new file mode 100644 index 0000000..0a01760 --- /dev/null +++ b/mpt_api_client/mpt_client.py @@ -0,0 +1,67 @@ +from typing import Self + +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources import AsyncCommerce, Commerce + + +class AsyncMPTClient: + """MPT API Client.""" + + def __init__( + self, + http_client: AsyncHTTPClient | None = None, + ): + self.http_client = http_client or AsyncHTTPClient() + + @classmethod + def from_config(cls, api_token: str, base_url: str) -> Self: + """Create MPT client from configuration. + + Args: + api_token: MPT API Token + base_url: MPT Base URL + + Returns: + MPT Client + + """ + return cls(AsyncHTTPClient(base_url=base_url, api_token=api_token)) + + @property + def commerce(self) -> "AsyncCommerce": + """Commerce MPT API Client.""" + return AsyncCommerce(http_client=self.http_client) + + +class MPTClient: + """MPT API Client.""" + + def __init__( + self, + http_client: HTTPClient | None = None, + ): + self.http_client = http_client or HTTPClient() + + @classmethod + def from_config(cls, api_token: str, base_url: str) -> Self: + """Create MPT client from configuration. + + Args: + api_token: MPT API Token + base_url: MPT Base URL + + Returns: + MPT Client + + """ + return cls(HTTPClient(base_url=base_url, api_token=api_token)) + + @property + def commerce(self) -> "Commerce": + """Commerce MPT API Client. + + The Commerce API provides a comprehensive set of endpoints + for managing agreements, requests, subscriptions, and orders + within a vendor-client-ops ecosystem. + """ + return Commerce(http_client=self.http_client) diff --git a/mpt_api_client/mptclient.py b/mpt_api_client/mptclient.py deleted file mode 100644 index e5a1617..0000000 --- a/mpt_api_client/mptclient.py +++ /dev/null @@ -1,57 +0,0 @@ -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.registry import Registry, commerce -from mpt_api_client.resources import OrderCollectionClientBase - - -class MPTClientBase: - """MPT API Client Base.""" - - def __init__( - self, - base_url: str | None = None, - api_key: str | None = None, - registry: Registry | None = None, - http_client: HTTPClient | None = None, - ): - self.http_client = http_client or HTTPClient(base_url=base_url, api_token=api_key) - self.registry: Registry = registry or Registry() - - def __getattr__(self, name): # type: ignore[no-untyped-def] - return self.registry.get(name)(http_client=self.http_client) - - -class MPTClient(MPTClientBase): - """MPT API Client.""" - - @property - def commerce(self) -> "CommerceMpt": - """Commerce MPT API Client. - - The Commerce API provides a comprehensive set of endpoints - for managing agreements, requests, subscriptions, and orders - within a vendor-client-ops ecosystem. - """ - return CommerceMpt(http_client=self.http_client, registry=commerce) - - -class CommerceMpt(MPTClientBase): - """Commerce MPT API Client.""" - - @property - def orders(self) -> OrderCollectionClientBase: - """Orders MPT API collection. - - The Orders API provides a comprehensive set of endpoints - for creating, updating, and retrieving orders. - - - - Returns: Order collection - - Examples: - active=RQLQuery("status=active") - for order in mpt.orders.filter(active).iterate(): - [...] - - """ - return self.registry.get("orders")(http_client=self.http_client) # type: ignore[return-value] diff --git a/mpt_api_client/registry.py b/mpt_api_client/registry.py deleted file mode 100644 index f7a3acf..0000000 --- a/mpt_api_client/registry.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from mpt_api_client.http.collection import CollectionClientBase - -ItemType = type[CollectionClientBase[Any, Any]] - - -class Registry: - """Registry for MPT collection clients.""" - - def __init__(self) -> None: - self.items: dict[str, ItemType] = {} # noqa: WPS110 - - def __call__(self, keyname: str) -> Callable[[ItemType], ItemType]: - """Decorator to register a CollectionBaseClient class. - - Args: - keyname: The key to register the class under - - Returns: - The decorator function - - Examples: - registry = Registry() - @registry("orders") - class OrderCollectionClient(CollectionBaseClient): - _endpoint = "/api/v1/orders" - _resource_class = Order - - registry.get("orders") == OrderCollectionClient - """ - - def decorator(cls: ItemType) -> ItemType: - self.register(keyname, cls) - return cls - - return decorator - - def register(self, keyname: str, item: ItemType) -> None: # noqa: WPS110 - """Register a collection client class with a keyname. - - Args: - keyname: The key to register the client under - item: The collection client class to register - """ - self.items[keyname] = item - - def get(self, keyname: str) -> ItemType: - """Get a registered collection client class by keyname. - - Args: - keyname: The key to look up - - Returns: - The registered collection client class - - Raises: - KeyError: If keyname is not registered - """ - if keyname not in self.items: - raise KeyError(f"No collection client registered with keyname: {keyname}") - return self.items[keyname] - - def list_keys(self) -> list[str]: - """Get all registered keynames. - - Returns: - List of all registered keynames - """ - return list(self.items.keys()) - - -commerce = Registry() diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index 97ace29..9d0d49e 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -1,3 +1,3 @@ -from mpt_api_client.resources.order import Order, OrderCollectionClientBase, OrderResourceClient +from mpt_api_client.resources.commerce import AsyncCommerce, Commerce -__all__ = ["Order", "OrderCollectionClientBase", "OrderResourceClient"] # noqa: WPS410 +__all__ = ["AsyncCommerce", "Commerce"] # noqa: WPS410 diff --git a/mpt_api_client/resources/commerce/__init__.py b/mpt_api_client/resources/commerce/__init__.py new file mode 100644 index 0000000..d9922d6 --- /dev/null +++ b/mpt_api_client/resources/commerce/__init__.py @@ -0,0 +1,4 @@ +from mpt_api_client.resources.commerce.commerce import AsyncCommerce, Commerce +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + +__all__ = ["AsyncCommerce", "AsyncOrdersService", "Commerce", "OrdersService"] # noqa: WPS410 diff --git a/mpt_api_client/resources/commerce/commerce.py b/mpt_api_client/resources/commerce/commerce.py new file mode 100644 index 0000000..fc56c6e --- /dev/null +++ b/mpt_api_client/resources/commerce/commerce.py @@ -0,0 +1,26 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + + +class Commerce: + """Commerce MPT API Module.""" + + def __init__(self, http_client: HTTPClient): + self.http_client = http_client + + @property + def orders(self) -> OrdersService: + """Order service.""" + return OrdersService(http_client=self.http_client) + + +class AsyncCommerce: + """Commerce MPT API Module.""" + + def __init__(self, http_client: AsyncHTTPClient): + self.http_client = http_client + + @property + def orders(self) -> AsyncOrdersService: + """Order service.""" + return AsyncOrdersService(http_client=self.http_client) diff --git a/mpt_api_client/resources/commerce/orders.py b/mpt_api_client/resources/commerce/orders.py new file mode 100644 index 0000000..a2671b0 --- /dev/null +++ b/mpt_api_client/resources/commerce/orders.py @@ -0,0 +1,169 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.models import Model, ResourceData + + +class Order(Model): + """Order resource.""" + + +class OrdersServiceConfig: + """Orders service config.""" + + _endpoint = "/public/v1/commerce/orders" + _model_class = Order + _collection_key = "data" + + +class OrdersService(Service[Order], OrdersServiceConfig): + """Orders client.""" + + def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to validate state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "validate", json=resource_data) + + def process(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to process state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "process", json=resource_data) + + def query(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to query state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "query", json=resource_data) + + def complete(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to complete state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "complete", json=resource_data) + + def fail(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to fail state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "fail", json=resource_data) + + def notify(self, resource_id: str, user: ResourceData) -> None: + """Notify user about order status. + + Args: + resource_id: Order resource ID + user: User data + """ + self._resource_do_request(resource_id, "POST", "notify", json=user) + + def template(self, resource_id: str) -> str: + """Render order template. + + Args: + resource_id: Order resource ID + + Returns: + Order template text in markdown format. + """ + response = self._resource_do_request(resource_id, "GET", "template") + return response.text + + +class AsyncOrdersService(AsyncService[Order], OrdersServiceConfig): + """Async Orders client.""" + + async def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to validate state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "validate", json=resource_data) + + async def process(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to process state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "process", json=resource_data) + + async def query(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to query state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "query", json=resource_data) + + async def complete(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to complete state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "complete", json=resource_data) + + async def fail(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to fail state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "fail", json=resource_data) + + async def notify(self, resource_id: str, resource_data: ResourceData) -> None: + """Notify user about order status. + + Args: + resource_id: Order resource ID + resource_data: User data to notify + """ + await self._resource_do_request(resource_id, "POST", "notify", json=resource_data) + + async def template(self, resource_id: str) -> str: + """Render order template. + + Args: + resource_id: Order resource ID + + Returns: + Order template text in markdown format. + """ + response = await self._resource_do_request(resource_id, "GET", "template") + return response.text diff --git a/mpt_api_client/resources/order.py b/mpt_api_client/resources/order.py deleted file mode 100644 index 79ba72f..0000000 --- a/mpt_api_client/resources/order.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Any - -from mpt_api_client.http.collection import CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection, Resource -from mpt_api_client.registry import commerce - - -class Order(Resource): - """Order resource.""" - - -class OrderResourceClient(ResourceBaseClient[Order]): - """Order resource client.""" - - _endpoint = "/public/v1/commerce/orders" - _resource_class = Order - - def validate(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to validate state. - - Args: - order: Order data will be updated - """ - response = self.do_action("POST", "validate", json=order) - return self._resource_class.from_response(response) - - def process(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to process state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "process", json=order) - - def query(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to query state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "query", json=order) - - def complete(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to complete state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "complete", json=order) - - def fail(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to fail state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "fail", json=order) - - def notify(self, user: dict[str, Any]) -> None: - """Notify user about order status. - - Args: - user: User data - """ - self.do_action("POST", "notify", json=user) - - def template(self) -> str: - """Render order template. - - Returns: - Order template text in markdown format. - """ - response = self.do_action("GET", "template") - return response.text - - -@commerce("orders") -class OrderCollectionClientBase(CollectionClientBase[Order, OrderResourceClient]): - """Orders client.""" - - _endpoint = "/public/v1/commerce/orders" - _resource_class = Order - _resource_client_class = OrderResourceClient - _collection_class = Collection[Order] diff --git a/mpt_api_client/rql/query_builder.py b/mpt_api_client/rql/query_builder.py index 3973331..29b4aec 100644 --- a/mpt_api_client/rql/query_builder.py +++ b/mpt_api_client/rql/query_builder.py @@ -9,7 +9,7 @@ QueryValue = str | bool | dt.date | dt.datetime | Numeric -def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS210 WPS231 +def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS231 """ Parse keyword arguments into RQL query expressions. @@ -112,7 +112,7 @@ def rql_encode(op: str, value: Any) -> str: raise TypeError(f"the `{op}` operator doesn't support the {type(value)} type.") -class RQLQuery: # noqa: WPS214 +class RQLQuery: """ Helper class to construct complex RQL queries. @@ -142,11 +142,11 @@ class RQLQuery: # noqa: WPS214 rql = RQLQuery().nested.field.eq('value') """ - OP_AND = "and" # noqa: WPS115 - OP_OR = "or" # noqa: WPS115 - OP_ANY = "any" # noqa: WPS115 - OP_ALL = "all" # noqa: WPS115 - OP_EXPRESSION = "expr" # noqa: WPS115 + OP_AND = "and" + OP_OR = "or" + OP_ANY = "any" + OP_ALL = "all" + OP_EXPRESSION = "expr" def __init__( # noqa: WPS211 self, diff --git a/setup.cfg b/setup.cfg index 9e8f2fd..8874bad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,22 +32,12 @@ extend-ignore = per-file-ignores = - mpt_api_client/rql/query_builder.py: - # Forbid blacklisted variable names - WPS110 - # Found `noqa` comments overuse - WPS402 - tests/http/collection/test_collection_client_iterate.py: - # Found too many module members - WPS202 - tests/http/collection/test_collection_client_fetch.py: - # Found too many module members - WPS202 - # Found magic number - WPS432 + mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214 + mpt_api_client/http/service.py: WPS214 + tests/http/test_async_service.py: WPS204 WPS202 + tests/http/test_service.py: WPS204 WPS202 tests/*: - # Allow magic strings + # Allow magic strings. WPS432 - # Found too many modules members + # Found too many modules members. WPS202 - diff --git a/tests/conftest.py b/tests/conftest.py index 6d7c6c7..8f507ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,23 @@ import pytest -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.models import Resource +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.models import Model API_TOKEN = "test-token" API_URL = "https://api.example.com" -class DummyResource(Resource): +class DummyModel(Model): """Dummy resource for testing.""" - _data_key = "data" + _data_key = None @pytest.fixture -def mpt_client(): +def http_client(): return HTTPClient(base_url=API_URL, api_token=API_TOKEN) + + +@pytest.fixture +def async_http_client(): + return AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) diff --git a/tests/http/collection/conftest.py b/tests/http/collection/conftest.py deleted file mode 100644 index fe5325e..0000000 --- a/tests/http/collection/conftest.py +++ /dev/null @@ -1,92 +0,0 @@ -import httpx -import pytest - - -@pytest.fixture -def single_page_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ], - "$meta": { - "pagination": { - "total": 2, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - -@pytest.fixture -def multi_page_response_page1(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ], - "$meta": { - "pagination": { - "total": 4, - "offset": 0, - "limit": 2, - } - }, - }, - ) - - -@pytest.fixture -def multi_page_response_page2(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-3", "name": "Resource 3"}, - {"id": "ID-4", "name": "Resource 4"}, - ], - "$meta": { - "pagination": { - "total": 4, - "offset": 2, - "limit": 2, - } - }, - }, - ) - - -@pytest.fixture -def empty_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [], - "$meta": { - "pagination": { - "total": 0, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - -@pytest.fixture -def no_meta_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [ - {"id": "ID-1", "name": "Resource 1"}, - {"id": "ID-2", "name": "Resource 2"}, - ] - }, - ) diff --git a/tests/http/collection/test_async_collection_client_create.py b/tests/http/collection/test_async_collection_client_create.py deleted file mode 100644 index 0427349..0000000 --- a/tests/http/collection/test_async_collection_client_create.py +++ /dev/null @@ -1,26 +0,0 @@ -import json - -import httpx -import pytest -import respx - - -@pytest.mark.asyncio -async def test_create_resource(async_collection_client): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(201, json={"data": new_resource_data}) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = await async_collection_client.create(resource_data) - - assert created_resource.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data diff --git a/tests/http/collection/test_async_collection_client_fetch.py b/tests/http/collection/test_async_collection_client_fetch.py deleted file mode 100644 index 3bcce0d..0000000 --- a/tests/http/collection/test_async_collection_client_fetch.py +++ /dev/null @@ -1,146 +0,0 @@ -import httpx -import pytest -import respx - -from mpt_api_client.rql import RQLQuery - - -@pytest.fixture -def list_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - -@pytest.fixture -def single_result_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Test Resource"}], - "$meta": {"pagination": {"total": 1, "offset": 0, "limit": 1}}, - }, - ) - - -@pytest.fixture -def no_results_response(): - return httpx.Response( - httpx.codes.OK, - json={"data": [], "$meta": {"pagination": {"total": 0, "offset": 0, "limit": 1}}}, - ) - - -@pytest.fixture -def multiple_results_response(): - return httpx.Response( - 200, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}, {"id": "ID-2", "name": "Resource 2"}], - "$meta": {"pagination": {"total": 2, "offset": 0, "limit": 1}}, - }, - ) - - -@pytest.fixture -def no_meta_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - -@pytest.fixture -def filter_status_active(): - return RQLQuery(status="active") - - -@pytest.mark.asyncio -async def test_fetch_one_success(async_collection_client, single_result_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - - resource = await async_collection_client.fetch_one() - - assert resource.id == "ID-1" - assert resource.name == "Test Resource" - assert mock_route.called - - first_request = mock_route.calls[0].request - assert "limit=1" in str(first_request.url) - assert "offset=0" in str(first_request.url) - - -@pytest.mark.asyncio -async def test_fetch_one_no_results(async_collection_client, no_results_response): - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) - - with pytest.raises(ValueError, match="Expected one result, but got zero results"): - await async_collection_client.fetch_one() - - -@pytest.mark.asyncio -async def test_fetch_one_multiple_results(async_collection_client, multiple_results_response): - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=multiple_results_response - ) - - with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): - await async_collection_client.fetch_one() - - -@pytest.mark.asyncio -async def test_fetch_one_with_filters( - async_collection_client, single_result_response, filter_status_active -): - filtered_collection = ( - async_collection_client.filter(filter_status_active) - .select("id", "name") - .order_by("created") - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - resource = await filtered_collection.fetch_one() - - assert resource.id == "ID-1" - assert mock_route.called - - first_request = mock_route.calls[0].request - assert first_request.method == "GET" - assert first_request.url == ( - "https://api.example.com/api/v1/test" - "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" - ) - - -@pytest.mark.asyncio -async def test_fetch_page_with_filter( - async_collection_client, list_response, filter_status_active -) -> None: - custom_collection = ( - async_collection_client.filter(filter_status_active) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - - expected_url = ( - "https://api.example.com/api/v1/test?limit=10&offset=5" - "&order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=list_response - ) - collection_results = await custom_collection.fetch_page(limit=10, offset=5) - - assert collection_results.to_list() == [{"id": "ID-1"}] - assert mock_route.called - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "GET" - assert request.url == expected_url diff --git a/tests/http/collection/test_async_collection_client_init.py b/tests/http/collection/test_async_collection_client_init.py deleted file mode 100644 index 81e90ce..0000000 --- a/tests/http/collection/test_async_collection_client_init.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from mpt_api_client.http.client import HTTPClientAsync -from mpt_api_client.rql.query_builder import RQLQuery -from tests.http.conftest import DummyAsyncCollectionClientBase - - -@pytest.fixture -def mock_mpt_client_async(api_url, api_token): - return HTTPClientAsync(base_url=api_url, api_token=api_token) - - -@pytest.fixture -def sample_rql_query(): - return RQLQuery(status="active") - - -def test_init_defaults(async_collection_client): - assert async_collection_client.query_rql is None - assert async_collection_client.query_order_by is None - assert async_collection_client.query_select is None - assert async_collection_client.build_url() == "/api/v1/test" - - -def test_init_with_filter(http_client_async, sample_rql_query): - collection_client = DummyAsyncCollectionClientBase( - http_client=http_client_async, - query_rql=sample_rql_query, - ) - - assert collection_client.query_rql == sample_rql_query - assert collection_client.query_order_by is None - assert collection_client.query_select is None - assert collection_client.build_url() == "/api/v1/test?eq(status,active)" diff --git a/tests/http/collection/test_async_collection_client_iterate.py b/tests/http/collection/test_async_collection_client_iterate.py deleted file mode 100644 index 88024bf..0000000 --- a/tests/http/collection/test_async_collection_client_iterate.py +++ /dev/null @@ -1,141 +0,0 @@ -import httpx -import pytest -import respx - -from mpt_api_client.rql import RQLQuery - - -@pytest.mark.asyncio -async def test_iterate_single_page(async_collection_client, single_page_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_page_response - ) - - resources = [resource async for resource in async_collection_client.iterate()] - - request = mock_route.calls[0].request - - assert len(resources) == 2 - assert resources[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} - assert resources[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} - assert mock_route.call_count == 1 - assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" - - -@pytest.mark.asyncio -async def test_iterate_multiple_pages( - async_collection_client, multi_page_response_page1, multi_page_response_page2 -): - with respx.mock: - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( - return_value=multi_page_response_page1 - ) - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( - return_value=multi_page_response_page2 - ) - - resources = [resource async for resource in async_collection_client.iterate(2)] - - assert len(resources) == 4 - assert resources[0].id == "ID-1" - assert resources[1].id == "ID-2" - assert resources[2].id == "ID-3" - assert resources[3].id == "ID-4" - - -@pytest.mark.asyncio -async def test_iterate_empty_results(async_collection_client, empty_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=empty_response - ) - - resources = [resource async for resource in async_collection_client.iterate()] - - assert len(resources) == 0 - assert mock_route.call_count == 1 - - -@pytest.mark.asyncio -async def test_iterate_no_meta(async_collection_client, no_meta_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=no_meta_response - ) - - resources = [resource async for resource in async_collection_client.iterate()] - - assert len(resources) == 2 - assert resources[0].id == "ID-1" - assert resources[1].id == "ID-2" - assert mock_route.call_count == 1 - - -@pytest.mark.asyncio -async def test_iterate_with_filters(async_collection_client): - filtered_collection = ( - async_collection_client.filter(RQLQuery(status="active")) - .select("id", "name") - .order_by("created") - ) - - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Active Resource"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - resources = [resource async for resource in filtered_collection.iterate()] - - assert len(resources) == 1 - assert resources[0].id == "ID-1" - assert resources[0].name == "Active Resource" - - request = mock_route.calls[0].request - assert ( - str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" - ) - - -@pytest.mark.asyncio -async def test_iterate_lazy_evaluation(async_collection_client): - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - iterator = async_collection_client.iterate() - - # No requests should be made until we start iterating - assert mock_route.call_count == 0 - - # Get first item to trigger the first request - first_resource = await anext(iterator) - - assert first_resource.id == "ID-1" - assert mock_route.call_count == 1 diff --git a/tests/http/collection/test_collection_client_create.py b/tests/http/collection/test_collection_client_create.py deleted file mode 100644 index ca207e2..0000000 --- a/tests/http/collection/test_collection_client_create.py +++ /dev/null @@ -1,24 +0,0 @@ -import json - -import httpx -import respx - - -def test_create_resource(collection_client): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(201, json={"data": new_resource_data}) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = collection_client.create(resource_data) - - assert created_resource.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data diff --git a/tests/http/collection/test_collection_client_fetch.py b/tests/http/collection/test_collection_client_fetch.py deleted file mode 100644 index 93d8e1a..0000000 --- a/tests/http/collection/test_collection_client_fetch.py +++ /dev/null @@ -1,135 +0,0 @@ -import httpx -import pytest -import respx - -from mpt_api_client.rql import RQLQuery - - -@pytest.fixture -def list_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - -@pytest.fixture -def single_result_response(): - return httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Test Resource"}], - "$meta": {"pagination": {"total": 1, "offset": 0, "limit": 1}}, - }, - ) - - -@pytest.fixture -def no_results_response(): - return httpx.Response( - httpx.codes.OK, - json={"data": [], "$meta": {"pagination": {"total": 0, "offset": 0, "limit": 1}}}, # noqa: WPS221 - ) - - -@pytest.fixture -def multiple_results_response(): - return httpx.Response( - 200, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}, {"id": "ID-2", "name": "Resource 2"}], - "$meta": {"pagination": {"total": 2, "offset": 0, "limit": 1}}, - }, - ) - - -@pytest.fixture -def no_meta_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - -@pytest.fixture -def filter_status_active(): - return RQLQuery(status="active") - - -def test_fetch_one_success(collection_client, single_result_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - - resource = collection_client.fetch_one() - - assert resource.id == "ID-1" - assert resource.name == "Test Resource" - assert mock_route.called - - first_request = mock_route.calls[0].request - assert "limit=1" in str(first_request.url) - assert "offset=0" in str(first_request.url) - - -def test_fetch_one_no_results(collection_client, no_results_response): - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) - - with pytest.raises(ValueError, match="Expected one result, but got zero results"): - collection_client.fetch_one() - - -def test_fetch_one_multiple_results(collection_client, multiple_results_response): - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=multiple_results_response - ) - - with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): - collection_client.fetch_one() - - -def test_fetch_one_with_filters(collection_client, single_result_response, filter_status_active): - filtered_collection = ( - collection_client.filter(filter_status_active).select("id", "name").order_by("created") - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - resource = filtered_collection.fetch_one() - - assert resource.id == "ID-1" - assert mock_route.called - - first_request = mock_route.calls[0].request - assert first_request.method == "GET" - assert first_request.url == ( - "https://api.example.com/api/v1/test" - "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" - ) - - -def test_fetch_page_with_filter(collection_client, list_response, filter_status_active) -> None: - custom_collection = ( - collection_client.filter(filter_status_active) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - - expected_url = ( - "https://api.example.com/api/v1/test?limit=10&offset=5" - "&order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=list_response - ) - collection_results = custom_collection.fetch_page(limit=10, offset=5) - - assert collection_results.to_list() == [{"id": "ID-1"}] - assert mock_route.called - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "GET" - assert request.url == expected_url diff --git a/tests/http/collection/test_collection_client_init.py b/tests/http/collection/test_collection_client_init.py deleted file mode 100644 index 5003a0c..0000000 --- a/tests/http/collection/test_collection_client_init.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest - -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.rql.query_builder import RQLQuery -from tests.http.conftest import DummyCollectionClientBase - - -@pytest.fixture -def mock_mpt_client(api_url, api_token): - return HTTPClient(base_url=api_url, api_token=api_token) - - -@pytest.fixture -def sample_rql_query(): - return RQLQuery(status="active") - - -def test_init_defaults(http_client): - collection_client = DummyCollectionClientBase(http_client=http_client) - - assert collection_client.query_rql is None - assert collection_client.query_order_by is None - assert collection_client.query_select is None - assert collection_client.build_url() == "/api/v1/test" - - -def test_init_with_filter(http_client, sample_rql_query): - collection_client = DummyCollectionClientBase( - http_client=http_client, - query_rql=sample_rql_query, - ) - - assert collection_client.query_rql == sample_rql_query - assert collection_client.query_order_by is None - assert collection_client.query_select is None - assert collection_client.build_url() == "/api/v1/test?eq(status,active)" diff --git a/tests/http/collection/test_collection_client_iterate.py b/tests/http/collection/test_collection_client_iterate.py deleted file mode 100644 index 2ce0211..0000000 --- a/tests/http/collection/test_collection_client_iterate.py +++ /dev/null @@ -1,144 +0,0 @@ -import httpx -import pytest -import respx - -from mpt_api_client.rql import RQLQuery - - -def test_iterate_single_page(collection_client, single_page_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_page_response - ) - - resources = list(collection_client.iterate()) - request = mock_route.calls[0].request - - assert len(resources) == 2 - assert resources[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} - assert resources[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} - assert mock_route.call_count == 1 - assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" - - -def test_iterate_multiple_pages( - collection_client, multi_page_response_page1, multi_page_response_page2 -): - with respx.mock: - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( - return_value=multi_page_response_page1 - ) - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( - return_value=multi_page_response_page2 - ) - - resources = list(collection_client.iterate(2)) - - assert len(resources) == 4 - assert resources[0].id == "ID-1" - assert resources[1].id == "ID-2" - assert resources[2].id == "ID-3" - assert resources[3].id == "ID-4" - - -def test_iterate_empty_results(collection_client, empty_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=empty_response - ) - - resources = list(collection_client.iterate(2)) - - assert len(resources) == 0 - assert mock_route.call_count == 1 - - -def test_iterate_no_meta(collection_client, no_meta_response): - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=no_meta_response - ) - - resources = list(collection_client.iterate()) - - assert len(resources) == 2 - assert resources[0].id == "ID-1" - assert resources[1].id == "ID-2" - assert mock_route.call_count == 1 - - -def test_iterate_with_filters(collection_client): - filtered_collection = ( - collection_client.filter(RQLQuery(status="active")).select("id", "name").order_by("created") - ) - - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Active Resource"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - resources = list(filtered_collection.iterate()) - - assert len(resources) == 1 - assert resources[0].id == "ID-1" - assert resources[0].name == "Active Resource" - - request = mock_route.calls[0].request - assert ( - str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" - ) - - -def test_iterate_lazy_evaluation(collection_client): - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - iterator = collection_client.iterate() - - assert mock_route.call_count == 0 - - first_resource = next(iterator) - - assert mock_route.call_count == 1 - assert first_resource.id == "ID-1" - - -def test_iterate_handles_api_errors(collection_client): - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=httpx.Response( - httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} - ) - ) - - iterator = collection_client.iterate() - - with pytest.raises(httpx.HTTPStatusError): - list(iterator) diff --git a/tests/http/collection/test_collection_get.py b/tests/http/collection/test_collection_get.py deleted file mode 100644 index db5076b..0000000 --- a/tests/http/collection/test_collection_get.py +++ /dev/null @@ -1,4 +0,0 @@ -def test_get(collection_client): - resource = collection_client.get("RES-123") - assert resource.resource_id_ == "RES-123" - assert isinstance(resource, collection_client._resource_client_class) # noqa: SLF001 diff --git a/tests/http/collection/test_collection_mixin.py b/tests/http/collection/test_collection_mixin.py deleted file mode 100644 index b9529de..0000000 --- a/tests/http/collection/test_collection_mixin.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest - -from mpt_api_client.rql.query_builder import RQLQuery - - -def test_filter(collection_client): - filter_query = RQLQuery(status="active") - - new_collection = collection_client.filter(filter_query) - - assert collection_client.query_rql is None - assert new_collection != collection_client - assert new_collection.query_rql == filter_query - - -def test_multiple_filters(collection_client) -> None: - filter_query = RQLQuery(status="active") - filter_query2 = RQLQuery(name="test") - - new_collection = collection_client.filter(filter_query).filter(filter_query2) - - assert collection_client.query_rql is None - assert new_collection.query_rql == filter_query & filter_query2 - - -def test_select(collection_client) -> None: - new_collection = collection_client.select("agreement", "-product") - - assert collection_client.query_select is None - assert new_collection != collection_client - assert new_collection.query_select == ["agreement", "-product"] - - -def test_select_exception(collection_client) -> None: - with pytest.raises(ValueError): - collection_client.select("agreement").select("product") - - -def test_order_by(collection_client): - new_collection = collection_client.order_by("created", "-name") - - assert collection_client.query_order_by is None - assert new_collection != collection_client - assert new_collection.query_order_by == ["created", "-name"] - - -def test_order_by_exception(collection_client): - with pytest.raises( - ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." - ): - collection_client.order_by("created").order_by("name") - - -def test_url(collection_client) -> None: - filter_query = RQLQuery(status="active") - custom_collection = ( - collection_client.filter(filter_query) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - - url = custom_collection.build_url() - - assert custom_collection != collection_client - assert url == ( - "/api/v1/test?order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - - -def test_clone(collection_client) -> None: - configured = ( - collection_client.filter(RQLQuery(status="active")) - .order_by("created", "-name") - .select("agreement", "-product") - ) - - cloned = configured.clone(configured) - - assert cloned is not configured - assert isinstance(cloned, configured.__class__) - assert cloned.http_client is configured.http_client - assert str(cloned.query_rql) == str(configured.query_rql) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 58178f1..dc1b350 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,61 +1,157 @@ +import httpx import pytest -from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.collection import AsyncCollectionClientBase, CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection -from tests.conftest import DummyResource +from mpt_api_client import RQLQuery +from mpt_api_client.http.async_service import AsyncService +from mpt_api_client.http.service import Service +from tests.conftest import DummyModel -class DummyResourceClient(ResourceBaseClient[DummyResource]): - _endpoint = "/api/v1/test-resource" - _resource_class = DummyResource +class DummyService(Service[DummyModel]): + _endpoint = "/api/v1/test" + _model_class = DummyModel -class DummyCollectionClientBase(CollectionClientBase[DummyResource, DummyResourceClient]): +class AsyncDummyService(AsyncService[DummyModel]): _endpoint = "/api/v1/test" - _resource_class = DummyResource - _resource_client_class = DummyResourceClient - _collection_class = Collection[DummyResource] + _model_class = DummyModel -class DummyAsyncCollectionClientBase(AsyncCollectionClientBase[DummyResource, DummyResourceClient]): - _endpoint = "/api/v1/test" - _resource_class = DummyResource - _resource_client_class = DummyResourceClient - _collection_class = Collection[DummyResource] +@pytest.fixture +def dummy_service(http_client) -> DummyService: + return DummyService(http_client=http_client) + + +@pytest.fixture +def async_dummy_service(async_http_client) -> AsyncDummyService: + return AsyncDummyService(http_client=async_http_client) + + +@pytest.fixture +def single_page_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ], + "$meta": { + "pagination": { + "total": 2, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + +@pytest.fixture +def multi_page_response_page1(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ], + "$meta": { + "pagination": { + "total": 4, + "offset": 0, + "limit": 2, + } + }, + }, + ) + + +@pytest.fixture +def multi_page_response_page2(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-3", "name": "Resource 3"}, + {"id": "ID-4", "name": "Resource 4"}, + ], + "$meta": { + "pagination": { + "total": 4, + "offset": 2, + "limit": 2, + } + }, + }, + ) @pytest.fixture -def api_url(): - return "https://api.example.com" +def empty_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [], + "$meta": { + "pagination": { + "total": 0, + "offset": 0, + "limit": 100, + } + }, + }, + ) @pytest.fixture -def api_token(): - return "test-token" +def no_meta_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [ + {"id": "ID-1", "name": "Resource 1"}, + {"id": "ID-2", "name": "Resource 2"}, + ] + }, + ) @pytest.fixture -def http_client(api_url, api_token): - return HTTPClient(base_url=api_url, api_token=api_token) +def list_response(): + return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) @pytest.fixture -def http_client_async(api_url, api_token): - return HTTPClientAsync(base_url=api_url, api_token=api_token) +def single_result_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Test Resource"}], + "$meta": {"pagination": {"total": 1, "offset": 0, "limit": 1}}, + }, + ) @pytest.fixture -def resource_client(http_client): - return DummyResourceClient(http_client=http_client, resource_id="RES-123") +def no_results_response(): + return httpx.Response( + httpx.codes.OK, + json={"data": [], "$meta": {"pagination": {"total": 0, "offset": 0, "limit": 1}}}, + ) @pytest.fixture -def collection_client(http_client) -> DummyCollectionClientBase: - return DummyCollectionClientBase(http_client=http_client) +def multiple_results_response(): + return httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}, {"id": "ID-2", "name": "Resource 2"}], + "$meta": {"pagination": {"total": 2, "offset": 0, "limit": 1}}, + }, + ) @pytest.fixture -def async_collection_client(http_client_async) -> DummyAsyncCollectionClientBase: - return DummyAsyncCollectionClientBase(http_client=http_client_async) +def filter_status_active(): + return RQLQuery(status="active") diff --git a/tests/http/resource/test_resource_client_fetch.py b/tests/http/resource/test_resource_client_fetch.py deleted file mode 100644 index 646dbbd..0000000 --- a/tests/http/resource/test_resource_client_fetch.py +++ /dev/null @@ -1,114 +0,0 @@ -import httpx -import pytest -import respx - - -def test_fetch_success(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Test Resource", "status": "active"}}, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.fetch() - - assert resource.to_dict() == {"id": "RES-123", "name": "Test Resource", "status": "active"} - assert mock_route.called - assert mock_route.call_count == 1 - assert resource_client.resource_ is not None - - -def test_get_attribute(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - assert resource_client.id == "RES-123" - assert resource_client.contact.name == "Albert" - assert mock_route.call_count == 1 - - -def test_set_attribute(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource_client.status = "disabled" - resource_client.contact.name = "Alice" - - assert resource_client.status == "disabled" - assert resource_client.contact.name == "Alice" - - -def test_fetch_not_found(resource_client): - error_response = httpx.Response(httpx.codes.NOT_FOUND, json={"error": "Resource not found"}) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=error_response - ) - - with pytest.raises(httpx.HTTPStatusError): - resource_client.fetch() - - -def test_fetch_server_error(resource_client): - error_response = httpx.Response( - httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal server error"} - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=error_response - ) - - with pytest.raises(httpx.HTTPStatusError): - resource_client.fetch() - - -def test_fetch_with_special_characters_in_id(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, json={"data": {"id": "RES-123", "name": "Special Resource"}} - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.fetch() - - assert resource.to_dict() == {"id": "RES-123", "name": "Special Resource"} - assert mock_route.called - - -def test_fetch_verifies_correct_url_construction(resource_client): - expected_response = httpx.Response(httpx.codes.OK, json={"data": {"id": "RES-123"}}) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource_client.fetch() - - request = mock_route.calls[0].request - - assert request.method == "GET" - assert str(request.url) == "https://api.example.com/api/v1/test-resource/RES-123" diff --git a/tests/http/resource/test_resource_client_update.py b/tests/http/resource/test_resource_client_update.py deleted file mode 100644 index 9752492..0000000 --- a/tests/http/resource/test_resource_client_update.py +++ /dev/null @@ -1,85 +0,0 @@ -import httpx -import pytest -import respx - - -def test_update_resource_successfully(resource_client): - update_data = {"name": "Updated Resource Name", "status": "modified", "version": 2} - expected_response = httpx.Response( - httpx.codes.OK, - json={ - "data": { - "id": "RES-123", - "name": "Updated Resource Name", - "status": "modified", - "version": 2, - } - }, - ) - - with respx.mock: - mock_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.update(update_data) - - assert resource.to_dict() == { - "id": "RES-123", - "name": "Updated Resource Name", - "status": "modified", - "version": 2, - } - assert mock_route.called - assert mock_route.call_count == 1 - - -def test_save_resource_successfully(resource_client): - fetch_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Original Name", "status": "active"}}, - ) - save_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Modified Name", "status": "active"}}, - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=fetch_response - ) - mock_put_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=save_response - ) - - resource_client.fetch() - resource_client.name = "Modified Name" - resource_client.save() - - assert resource_client.resource_.to_dict() == { - "id": "RES-123", - "name": "Modified Name", - "status": "active", - } - assert mock_put_route.called - assert mock_put_route.call_count == 1 - - -def test_save_raises_error_when_resource_not_set(resource_client): - with pytest.raises(ValueError, match="Unable to save resource that has not been set"): - resource_client.save() - - -def test_delete_resource_successfully(resource_client): - delete_response = httpx.Response(httpx.codes.NO_CONTENT) - - with respx.mock: - mock_delete_route = respx.delete( - "https://api.example.com/api/v1/test-resource/RES-123" - ).mock(return_value=delete_response) - - resource_client.delete() - - assert resource_client.resource_ is None - assert mock_delete_route.called - assert mock_delete_route.call_count == 1 diff --git a/tests/http/test_async_client.py b/tests/http/test_async_client.py index c320e79..f67f975 100644 --- a/tests/http/test_async_client.py +++ b/tests/http/test_async_client.py @@ -2,45 +2,45 @@ import respx from httpx import ConnectTimeout, Response, codes -from mpt_api_client.http.client import HTTPClientAsync +from mpt_api_client.http.async_client import AsyncHTTPClient from tests.conftest import API_TOKEN, API_URL -def test_mpt_client_initialization(): - client = HTTPClientAsync(base_url=API_URL, api_token=API_TOKEN) +def test_async_http_initialization(): + client = AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) assert client.base_url == API_URL assert client.headers["Authorization"] == "Bearer test-token" assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" -def test_env_initialization(monkeypatch): +def test_async_http_env_initialization(monkeypatch): monkeypatch.setenv("MPT_TOKEN", API_TOKEN) monkeypatch.setenv("MPT_URL", API_URL) - client = HTTPClientAsync() + client = AsyncHTTPClient() assert client.base_url == API_URL assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" -def test_mpt_client_without_token(): +def test_async_http_without_token(): with pytest.raises(ValueError): - HTTPClientAsync(base_url=API_URL) + AsyncHTTPClient(base_url=API_URL) -def test_mpt_client_without_url(): +def test_async_http_without_url(): with pytest.raises(ValueError): - HTTPClientAsync(api_token=API_TOKEN) + AsyncHTTPClient(api_token=API_TOKEN) @respx.mock -async def test_mock_call_success(http_client_async): +async def test_async_http_call_success(async_http_client): success_route = respx.get(f"{API_URL}/").mock( return_value=Response(200, json={"message": "Hello, World!"}) ) - success_response = await http_client_async.get("/") + success_response = await async_http_client.get("/") assert success_response.status_code == codes.OK assert success_response.json() == {"message": "Hello, World!"} @@ -48,10 +48,10 @@ async def test_mock_call_success(http_client_async): @respx.mock -async def test_mock_call_failure(http_client_async): +async def test_async_http_call_failure(async_http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(ConnectTimeout): - await http_client_async.get("/timeout") + await async_http_client.get("/timeout") assert timeout_route.called diff --git a/tests/http/test_async_service.py b/tests/http/test_async_service.py new file mode 100644 index 0000000..cf18899 --- /dev/null +++ b/tests/http/test_async_service.py @@ -0,0 +1,305 @@ +import json + +import httpx +import pytest +import respx + +from tests.conftest import DummyModel +from tests.http.conftest import AsyncDummyService + + +async def test_async_fetch_one_success(async_dummy_service, single_result_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + resource = await async_dummy_service.fetch_one() + + assert resource.id == "ID-1" + assert resource.name == "Test Resource" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert "limit=1" in str(first_request.url) + assert "offset=0" in str(first_request.url) + + +async def test_async_fetch_one_no_results(async_dummy_service, no_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) + + with pytest.raises(ValueError, match="Expected one result, but got zero results"): + await async_dummy_service.fetch_one() + + +async def test_async_fetch_one_multiple_results(async_dummy_service, multiple_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=multiple_results_response + ) + + with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): + await async_dummy_service.fetch_one() + + +async def test_async_fetch_one_with_filters( + async_dummy_service, single_result_response, filter_status_active +): + filtered_collection = ( + async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + resource = await filtered_collection.fetch_one() + + assert resource.id == "ID-1" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert first_request.method == "GET" + assert first_request.url == ( + "https://api.example.com/api/v1/test" + "?limit=1&offset=0&order=created" + "&select=id,name&eq(status,active)" + ) + + +async def test_async_fetch_page_with_filter( + async_dummy_service, list_response, filter_status_active +) -> None: + custom_collection = ( + async_dummy_service.filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + + expected_url = ( + "https://api.example.com/api/v1/test?limit=10&offset=5" + "&order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=list_response + ) + collection_results = await custom_collection.fetch_page(limit=10, offset=5) + + assert collection_results.to_list() == [{"id": "ID-1"}] + assert mock_route.called + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + + +def test_async_init_defaults(async_dummy_service): + assert async_dummy_service.query_rql is None + assert async_dummy_service.query_order_by is None + assert async_dummy_service.query_select is None + assert async_dummy_service.build_url() == "/api/v1/test" + + +def test_async_init_with_filter(async_http_client, filter_status_active): + collection_client = AsyncDummyService( + http_client=async_http_client, + query_rql=filter_status_active, + ) + + assert collection_client.query_rql == filter_status_active + assert collection_client.query_order_by is None + assert collection_client.query_select is None + assert collection_client.build_url() == "/api/v1/test?eq(status,active)" + + +async def test_async_iterate_single_page(async_dummy_service, single_page_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_page_response + ) + + resources = [resource async for resource in async_dummy_service.iterate()] + + request = mock_route.calls[0].request + + assert len(resources) == 2 + assert resources[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} + assert resources[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} + assert mock_route.call_count == 1 + assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" + + +async def test_async_iterate_multiple_pages( + async_dummy_service, multi_page_response_page1, multi_page_response_page2 +): + with respx.mock: + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + resources = [resource async for resource in async_dummy_service.iterate(2)] + + assert len(resources) == 4 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert resources[2].id == "ID-3" + assert resources[3].id == "ID-4" + + +async def test_async_iterate_empty_results(async_dummy_service, empty_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=empty_response + ) + + resources = [resource async for resource in async_dummy_service.iterate()] + + assert len(resources) == 0 + assert mock_route.call_count == 1 + + +async def test_async_iterate_no_meta(async_dummy_service, no_meta_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=no_meta_response + ) + + resources = [resource async for resource in async_dummy_service.iterate()] + + assert len(resources) == 2 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert mock_route.call_count == 1 + + +async def test_async_iterate_with_filters(async_dummy_service, filter_status_active): + filtered_collection = ( + async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Active Resource"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + resources = [resource async for resource in filtered_collection.iterate()] + + assert len(resources) == 1 + assert resources[0].id == "ID-1" + assert resources[0].name == "Active Resource" + + request = mock_route.calls[0].request + assert ( + str(request.url) == "https://api.example.com/api/v1/test" + "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + ) + + +async def test_async_iterate_lazy_evaluation(async_dummy_service): + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + iterator = async_dummy_service.iterate() + + # No requests should be made until we start iterating + assert mock_route.call_count == 0 + + # Get first item to trigger the first request + first_resource = await anext(iterator) + + assert first_resource.id == "ID-1" + assert mock_route.call_count == 1 + + +async def test_async_create_resource(async_dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = await async_dummy_service.create(resource_data) + + assert created_resource.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data + + +async def test_async_delete_resource(async_dummy_service): # noqa: WPS210 + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + await async_dummy_service.delete("RES-123") + + assert mock_route.call_count == 1 + + +async def test_async_update_resource(async_dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(httpx.codes.OK, json=resource_data) + + with respx.mock: + mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( + return_value=update_response + ) + + await async_dummy_service.update("RES-123", resource_data) + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert json.loads(request.content.decode()) == resource_data + + +async def test_async_get(async_dummy_service): + resource_data = {"id": "RES-123", "name": "Test Resource"} + with respx.mock: + respx.get("https://api.example.com/api/v1/test/RES-123").mock( + return_value=httpx.Response(httpx.codes.OK, json=resource_data) + ) + + resource = await async_dummy_service.get("RES-123") + assert isinstance(resource, DummyModel) + assert resource.to_dict() == resource_data diff --git a/tests/http/test_client.py b/tests/http/test_client.py index d388523..8e1a31d 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -6,7 +6,7 @@ from tests.conftest import API_TOKEN, API_URL -def test_mpt_client_initialization(): +def test_http_initialization(): client = HTTPClient(base_url=API_URL, api_token=API_TOKEN) assert client.base_url == API_URL @@ -24,18 +24,18 @@ def test_env_initialization(monkeypatch): assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" -def test_mpt_client_without_token(): +def test_http_without_token(): with pytest.raises(ValueError): HTTPClient(base_url=API_URL) -def test_mpt_client_without_url(): +def test_http_without_url(): with pytest.raises(ValueError): HTTPClient(api_token=API_TOKEN) @respx.mock -def test_mock_call_success(http_client): +def test_http_call_success(http_client): success_route = respx.get(f"{API_URL}/").mock( return_value=Response(200, json={"message": "Hello, World!"}) ) @@ -48,7 +48,7 @@ def test_mock_call_success(http_client): @respx.mock -def test_mock_call_failure(http_client): +def test_http_call_failure(http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(ConnectTimeout): diff --git a/tests/http/test_service.py b/tests/http/test_service.py new file mode 100644 index 0000000..f9d665a --- /dev/null +++ b/tests/http/test_service.py @@ -0,0 +1,391 @@ +import json + +import httpx +import pytest +import respx + +from mpt_api_client.rql import RQLQuery +from tests.conftest import DummyModel +from tests.http.conftest import DummyService + + +def test_sync_fetch_one_success(dummy_service, single_result_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + resource = dummy_service.fetch_one() + + assert resource.id == "ID-1" + assert resource.name == "Test Resource" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert "limit=1" in str(first_request.url) + assert "offset=0" in str(first_request.url) + + +def test_sync_fetch_one_no_results(dummy_service, no_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) + + with pytest.raises(ValueError, match="Expected one result, but got zero results"): + dummy_service.fetch_one() + + +def test_sync_fetch_one_multiple_results(dummy_service, multiple_results_response): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=multiple_results_response + ) + + with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): + dummy_service.fetch_one() + + +def test_sync_fetch_one_with_filters(dummy_service, single_result_response, filter_status_active): + filtered_collection = ( + dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + resource = filtered_collection.fetch_one() + + assert resource.id == "ID-1" + assert mock_route.called + + first_request = mock_route.calls[0].request + assert first_request.method == "GET" + assert first_request.url == ( + "https://api.example.com/api/v1/test" + "?limit=1&offset=0&order=created" + "&select=id,name&eq(status,active)" + ) + + +def test_sync_fetch_page_with_filter(dummy_service, list_response, filter_status_active) -> None: + custom_collection = ( + dummy_service.filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + + expected_url = ( + "https://api.example.com/api/v1/test?limit=10&offset=5" + "&order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=list_response + ) + collection_results = custom_collection.fetch_page(limit=10, offset=5) + + assert collection_results.to_list() == [{"id": "ID-1"}] + assert mock_route.called + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + + +def test_sync_get(dummy_service): + resource_data = {"id": "RES-123", "name": "Test Resource"} + with respx.mock: + respx.get("https://api.example.com/api/v1/test/RES-123").mock( + return_value=httpx.Response(httpx.codes.OK, json=resource_data) + ) + + resource = dummy_service.get("RES-123") + assert isinstance(resource, DummyModel) + assert resource.to_dict() == resource_data + + +def test_sync_init_defaults(http_client): + collection_client = DummyService(http_client=http_client) + + assert collection_client.query_rql is None + assert collection_client.query_order_by is None + assert collection_client.query_select is None + assert collection_client.build_url() == "/api/v1/test" + + +def test_sync_init_with_filter(http_client, filter_status_active): + collection_client = DummyService( + http_client=http_client, + query_rql=filter_status_active, + ) + + assert collection_client.query_rql == filter_status_active + assert collection_client.query_order_by is None + assert collection_client.query_select is None + assert collection_client.build_url() == "/api/v1/test?eq(status,active)" + + +def test_sync_iterate_single_page(dummy_service, single_page_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_page_response + ) + + resources = list(dummy_service.iterate()) + request = mock_route.calls[0].request + + assert len(resources) == 2 + assert resources[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} + assert resources[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} + assert mock_route.call_count == 1 + assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" + + +def test_sync_iterate_multiple_pages( + dummy_service, multi_page_response_page1, multi_page_response_page2 +): + with respx.mock: + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + resources = list(dummy_service.iterate(2)) + + assert len(resources) == 4 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert resources[2].id == "ID-3" + assert resources[3].id == "ID-4" + + +def test_sync_iterate_empty_results(dummy_service, empty_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=empty_response + ) + + resources = list(dummy_service.iterate(2)) + + assert len(resources) == 0 + assert mock_route.call_count == 1 + + +def test_sync_iterate_no_meta(dummy_service, no_meta_response): + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=no_meta_response + ) + + resources = list(dummy_service.iterate()) + + assert len(resources) == 2 + assert resources[0].id == "ID-1" + assert resources[1].id == "ID-2" + assert mock_route.call_count == 1 + + +def test_sync_iterate_with_filters(dummy_service, filter_status_active): + filtered_collection = ( + dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Active Resource"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + resources = list(filtered_collection.iterate()) + + assert len(resources) == 1 + assert resources[0].id == "ID-1" + assert resources[0].name == "Active Resource" + + request = mock_route.calls[0].request + assert ( + str(request.url) == "https://api.example.com/api/v1/test" + "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + ) + + +def test_sync_iterate_lazy_evaluation(dummy_service): + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + iterator = dummy_service.iterate() + + assert mock_route.call_count == 0 + + first_resource = next(iterator) + + assert mock_route.call_count == 1 + assert first_resource.id == "ID-1" + + +def test_sync_iterate_handles_api_errors(dummy_service): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} + ) + ) + + iterator = dummy_service.iterate() + + with pytest.raises(httpx.HTTPStatusError): + list(iterator) + + +def test_sync_create_resource(dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = dummy_service.create(resource_data) + + assert created_resource.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data + + +def test_sync_delete_resource(dummy_service): + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + dummy_service.delete("RES-123") + + assert mock_route.call_count == 1 + + +def test_sync_update_resource(dummy_service): + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(httpx.codes.OK, json=resource_data) + with respx.mock: + mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( + return_value=update_response + ) + + dummy_service.update("RES-123", resource_data) + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert json.loads(request.content.decode()) == resource_data + + +def test_sync_filter(dummy_service, filter_status_active): + new_collection = dummy_service.filter(filter_status_active) + + assert dummy_service.query_rql is None + assert new_collection != dummy_service + assert new_collection.query_rql == filter_status_active + + +def test_sync_multiple_filters(dummy_service) -> None: + filter_query = RQLQuery(status="active") + filter_query2 = RQLQuery(name="test") + + new_collection = dummy_service.filter(filter_query).filter(filter_query2) + + assert dummy_service.query_rql is None + assert new_collection.query_rql == filter_query & filter_query2 + + +def test_sync_select(dummy_service) -> None: + new_collection = dummy_service.select("agreement", "-product") + + assert dummy_service.query_select is None + assert new_collection != dummy_service + assert new_collection.query_select == ["agreement", "-product"] + + +def test_sync_select_exception(dummy_service) -> None: + with pytest.raises(ValueError): + dummy_service.select("agreement").select("product") + + +def test_sync_order_by(dummy_service): + new_collection = dummy_service.order_by("created", "-name") + + assert dummy_service.query_order_by is None + assert new_collection != dummy_service + assert new_collection.query_order_by == ["created", "-name"] + + +def test_sync_order_by_exception(dummy_service): + with pytest.raises( + ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." + ): + dummy_service.order_by("created").order_by("name") + + +def test_sync_url(dummy_service, filter_status_active) -> None: + custom_collection = ( + dummy_service.filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + + url = custom_collection.build_url() + + assert custom_collection != dummy_service + assert url == ( + "/api/v1/test?order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + + +def test_sync_clone(dummy_service, filter_status_active) -> None: + configured = ( + dummy_service.filter(filter_status_active) + .order_by("created", "-name") + .select("agreement", "-product") + ) + + cloned = configured.clone() + + assert cloned is not configured + assert isinstance(cloned, configured.__class__) + assert cloned.http_client is configured.http_client + assert str(cloned.query_rql) == str(configured.query_rql) diff --git a/tests/models/collection/conftest.py b/tests/models/collection/conftest.py index e2abbf6..2f17e81 100644 --- a/tests/models/collection/conftest.py +++ b/tests/models/collection/conftest.py @@ -1,11 +1,7 @@ import pytest -from mpt_api_client.models import Collection, Resource - - -@pytest.fixture -def meta_data(): - return {"pagination": {"limit": 10, "offset": 0, "total": 3}, "ignored": ["field1"]} +from mpt_api_client.models import Collection +from tests.conftest import DummyModel @pytest.fixture @@ -17,14 +13,16 @@ def response_collection_data(): ] -TestCollection = Collection[Resource] +@pytest.fixture +def empty_collection(): + return Collection() @pytest.fixture -def empty_collection(): - return TestCollection() +def collection_items(response_collection_data): + return [DummyModel(resource_data) for resource_data in response_collection_data] @pytest.fixture -def collection(response_collection_data): - return TestCollection(response_collection_data) +def collection(collection_items): + return Collection(collection_items) diff --git a/tests/models/collection/test_collection_custom_key.py b/tests/models/collection/test_collection_custom_key.py deleted file mode 100644 index 31aa251..0000000 --- a/tests/models/collection/test_collection_custom_key.py +++ /dev/null @@ -1,21 +0,0 @@ -from httpx import Response - -from mpt_api_client.models.collection import Collection -from mpt_api_client.models.resource import Resource - - -class ChargeResourceMock(Collection[Resource]): - _data_key = "charge" - - -def charge(charge_id, amount) -> dict[str, int]: - return {"id": charge_id, "amount": amount} - - -def test_custom_data_key(): - payload = {"charge": [charge(1, 100), charge(2, 101)]} - response = Response(200, json=payload) - - resource = ChargeResourceMock.from_response(response) - - assert resource[0].to_dict() == charge(1, 100) diff --git a/tests/models/collection/test_collection_init.py b/tests/models/collection/test_collection_init.py index 8aecf8f..9aa1f1c 100644 --- a/tests/models/collection/test_collection_init.py +++ b/tests/models/collection/test_collection_init.py @@ -1,11 +1,3 @@ -import pytest -from httpx import Response - -from mpt_api_client.models.collection import Collection -from mpt_api_client.models.meta import Meta -from tests.models.collection.conftest import TestCollection - - def test_generic_collection_empty(empty_collection): assert empty_collection.meta is None assert len(empty_collection) == 0 @@ -16,33 +8,5 @@ def test_generic_collection_empty(empty_collection): def test_generic_collection_with_data(collection, response_collection_data): assert len(collection) == 3 assert bool(collection) is True - assert collection.to_list() == response_collection_data - - -def test_from_response(meta_data, response_collection_data): - response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) - expected_meta = Meta.from_response(response) - - collection = TestCollection.from_response(response) - - assert collection.to_list() == response_collection_data - assert collection.meta == expected_meta - assert len(collection) == 3 - - -def test_wrong_data_type_from_response(): - response = Response(200, json={"data": {"not": "a list"}}) - - with pytest.raises( - TypeError, match=r"Response `data` must be a list for collection endpoints." - ): - Collection.from_response(response) - - -def test_collection_with_meta(meta_data, response_collection_data): - response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) - meta = Meta.from_response(response) - - collection = TestCollection.from_response(response) - - assert collection.meta == meta + for resource in collection.to_list(): + assert isinstance(resource, dict) diff --git a/tests/models/collection/test_collection_iteration.py b/tests/models/collection/test_collection_iteration.py index 0c6f716..df8c43b 100644 --- a/tests/models/collection/test_collection_iteration.py +++ b/tests/models/collection/test_collection_iteration.py @@ -1,7 +1,5 @@ import pytest -from tests.models.collection.conftest import TestCollection - def test_iteration(collection): resources = list(collection) @@ -9,13 +7,11 @@ def test_iteration(collection): assert len(resources) == 3 -def test_iteration_next(response_collection_data): - collection = TestCollection(response_collection_data) - +def test_iteration_next(collection, response_collection_data): iterator = iter(collection) - assert next(iterator).id == response_collection_data[0]["id"] - assert next(iterator).id == response_collection_data[1]["id"] - assert next(iterator).id == response_collection_data[2]["id"] + assert next(iterator).to_dict() == response_collection_data[0] + assert next(iterator).to_dict() == response_collection_data[1] + assert next(iterator).to_dict() == response_collection_data[2] # Check that iterator is exhausted with pytest.raises(StopIteration): diff --git a/tests/models/collection/test_collection_list.py b/tests/models/collection/test_collection_list.py index 2cd715b..ff0ea5f 100644 --- a/tests/models/collection/test_collection_list.py +++ b/tests/models/collection/test_collection_list.py @@ -1,11 +1,7 @@ import pytest -from tests.models.collection.conftest import TestCollection - - -def test_getitem_access(response_collection_data): - collection = TestCollection(response_collection_data) +def test_getitem_access(collection, response_collection_data): assert collection[0].to_dict() == response_collection_data[0] assert collection[1].to_dict() == response_collection_data[1] assert collection[2].to_dict() == response_collection_data[2] @@ -16,22 +12,17 @@ def test_getitem_out_of_bounds(collection): collection[10] -def test_length(empty_collection, response_collection_data): - collection = TestCollection(response_collection_data) - +def test_length(empty_collection, collection): assert len(empty_collection) == 0 assert len(collection) == 3 -def test_bool_conversion(empty_collection, response_collection_data): - collection_with_data = TestCollection(response_collection_data) - +def test_bool_conversion(empty_collection, collection, response_collection_data): assert bool(empty_collection) is False - assert bool(collection_with_data) is True + assert bool(collection) is True -def test_to_list_method(response_collection_data): - collection = TestCollection(response_collection_data) +def test_to_list_method(collection, response_collection_data): resources = collection.to_list() assert resources == response_collection_data diff --git a/tests/models/resource/test_resource.py b/tests/models/resource/test_resource.py index dc0817b..4ab2ae3 100644 --- a/tests/models/resource/test_resource.py +++ b/tests/models/resource/test_resource.py @@ -1,7 +1,7 @@ import pytest from httpx import Response -from mpt_api_client.models import Meta, Resource +from mpt_api_client.models import Meta, Model @pytest.fixture @@ -10,7 +10,7 @@ def meta_data(): def test_resource_empty(): - resource = Resource() + resource = Model() assert resource.meta is None assert resource.to_dict() == {} @@ -21,7 +21,7 @@ def test_from_response(meta_data): response = Response(200, json=record_data | {"$meta": meta_data}) expected_meta = Meta.from_response(response) - resource = Resource.from_response(response) + resource = Model.from_response(response) assert resource.to_dict() == record_data assert resource.meta == expected_meta @@ -33,7 +33,7 @@ def test_attribute_getter(mocker, meta_data): response = Response(200, json=response_data) - resource = Resource.from_response(response) + resource = Model.from_response(response) assert resource.id == 1 assert resource.name.given == "Albert" @@ -41,7 +41,7 @@ def test_attribute_getter(mocker, meta_data): def test_attribute_setter(): resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} - resource = Resource(resource_data) + resource = Model(resource_data) resource.id = 2 resource.name.given = "John" @@ -53,4 +53,4 @@ def test_attribute_setter(): def test_wrong_data_type(): response = Response(200, json=1) with pytest.raises(TypeError, match=r"Response data must be a dict."): - Resource.from_response(response) + Model.from_response(response) diff --git a/tests/models/resource/test_resource_custom_key.py b/tests/models/resource/test_resource_custom_key.py index 25a80cb..f43aea2 100644 --- a/tests/models/resource/test_resource_custom_key.py +++ b/tests/models/resource/test_resource_custom_key.py @@ -1,9 +1,9 @@ from httpx import Response -from mpt_api_client.models import Resource +from mpt_api_client.models import Model -class ChargeResourceMock(Resource): +class ChargeResourceMock(Model): _data_key = "charge" diff --git a/tests/resources/commerce/test_commerce.py b/tests/resources/commerce/test_commerce.py new file mode 100644 index 0000000..0823bc2 --- /dev/null +++ b/tests/resources/commerce/test_commerce.py @@ -0,0 +1,57 @@ +from mpt_api_client.http import AsyncHTTPClient +from mpt_api_client.resources.commerce import AsyncCommerce, Commerce +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + + +def test_commerce_init(http_client): + commerce = Commerce(http_client=http_client) + + assert isinstance(commerce, Commerce) + assert commerce.http_client is http_client + + +def test_commerce_orders_property(http_client): + commerce = Commerce(http_client=http_client) + + orders_service = commerce.orders + + assert isinstance(orders_service, OrdersService) + assert orders_service.http_client is http_client + + +def test_commerce_orders_multiple_calls(http_client): + commerce = Commerce(http_client=http_client) + + orders_service = commerce.orders + order_service_additional = commerce.orders + + assert orders_service is not order_service_additional + assert isinstance(orders_service, OrdersService) + assert isinstance(order_service_additional, OrdersService) + + +def test_async_commerce_init(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + assert isinstance(commerce, AsyncCommerce) + assert commerce.http_client is async_http_client + + +def test_async_commerce_orders_property(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + orders_service = commerce.orders + + assert isinstance(orders_service, AsyncOrdersService) + assert orders_service.http_client is async_http_client + + +def test_async_commerce_orders_multiple_calls(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + orders_service = commerce.orders + orders_service_additional = commerce.orders + + assert orders_service is not orders_service_additional + assert isinstance(orders_service, AsyncOrdersService) + assert isinstance(orders_service_additional, AsyncOrdersService) diff --git a/tests/resources/commerce/test_orders.py b/tests/resources/commerce/test_orders.py new file mode 100644 index 0000000..683a3e5 --- /dev/null +++ b/tests/resources/commerce/test_orders.py @@ -0,0 +1,221 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, Order, OrdersService + + +@pytest.fixture +def orders_service(http_client): + return OrdersService(http_client=http_client) + + +@pytest.fixture +def async_orders_service(async_http_client): + return AsyncOrdersService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", {"id": "ORD-123", "status": "update"}), + ("process", {"id": "ORD-123", "status": "update"}), + ("query", {"id": "ORD-123", "status": "update"}), + ("complete", {"id": "ORD-123", "status": "update"}), + ("fail", {"id": "ORD-123", "status": "update"}), + ], +) +def test_custom_resource_actions(orders_service, action, input_status): + request_expected_content = b'{"id":"ORD-123","status":"update"}' + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + order = getattr(orders_service, action)("ORD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", None), + ("process", None), + ("query", None), + ("complete", None), + ("fail", None), + ], +) +def test_custom_resource_actions_no_data(orders_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + order = getattr(orders_service, action)("ORD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +def test_notify(orders_service): + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/orders/ORD-123/notify" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + content='{"status": "notified"}', + ) + ) + user_data = {"email": "user@example.com", "name": "John Doe"} + + orders_service.notify("ORD-123", user_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == b'{"email":"user@example.com","name":"John Doe"}' + + +def test_template(orders_service): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/commerce/orders/ORD-123/template" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "text/markdown"}, + content="# Order Template\n\nThis is a markdown template.", + ) + ) + + markdown_template = orders_service.template("ORD-123") + + assert mock_route.called + assert mock_route.call_count == 1 + assert markdown_template == "# Order Template\n\nThis is a markdown template." + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", {"id": "ORD-123", "status": "update"}), + ("process", {"id": "ORD-123", "status": "update"}), + ("query", {"id": "ORD-123", "status": "update"}), + ("complete", {"id": "ORD-123", "status": "update"}), + ("fail", {"id": "ORD-123", "status": "update"}), + ], +) +async def test_async_custom_resource_actions(async_orders_service, action, input_status): + request_expected_content = b'{"id":"ORD-123","status":"update"}' + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + order = await getattr(async_orders_service, action)("ORD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", None), + ("process", None), + ("query", None), + ("complete", None), + ("fail", None), + ], +) +async def test_async_custom_resource_actions_nodata(async_orders_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + order = await getattr(async_orders_service, action)("ORD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +async def test_async_notify(async_orders_service): + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/orders/ORD-123/notify" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + content='{"status": "notified"}', + ) + ) + user_data = {"email": "user@example.com", "name": "John Doe"} + + await async_orders_service.notify("ORD-123", user_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == b'{"email":"user@example.com","name":"John Doe"}' + + +async def test_async_template(async_orders_service): + template_content = "# Order Template\n\nThis is a markdown template." + with respx.mock: + respx.get("https://api.example.com/public/v1/commerce/orders/ORD-123/template").mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "text/markdown"}, + content=template_content, + ) + ) + + template = await async_orders_service.template("ORD-123") + + assert template == template_content diff --git a/tests/resources/orders/test_order_collection_client.py b/tests/resources/orders/test_order_collection_client.py deleted file mode 100644 index 08a2eaa..0000000 --- a/tests/resources/orders/test_order_collection_client.py +++ /dev/null @@ -1,6 +0,0 @@ -from mpt_api_client.resources.order import OrderCollectionClientBase - - -def test_order_collection_client(mpt_client): - order_cc = OrderCollectionClientBase(http_client=mpt_client) - assert order_cc.query_rql is None diff --git a/tests/resources/orders/test_order_resource_client.py b/tests/resources/orders/test_order_resource_client.py deleted file mode 100644 index 0639d61..0000000 --- a/tests/resources/orders/test_order_resource_client.py +++ /dev/null @@ -1,97 +0,0 @@ -from unittest.mock import Mock, patch - -import httpx -import pytest - -from mpt_api_client.resources.order import Order, OrderResourceClient - - -@pytest.fixture -def order_response(): - return httpx.Response( - status_code=200, - headers={"content-type": "application/json"}, - content='{"id": "order123", "status": "completed", "$meta": {"total": 1}}', - request=httpx.Request("POST", "https://api.example.com/orders"), - ) - - -@pytest.fixture -def order_client(): - with patch.object(OrderResourceClient, "do_action"): - client = OrderResourceClient(Mock(), "order123") - yield client - - -def test_validate(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "draft"} - - order = order_client.validate(order_data) - - order_client.do_action.assert_called_once_with("POST", "validate", json=order_data) - assert isinstance(order, Order) - - -def test_process(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "validated"} - - order = order_client.process(order_data) - - order_client.do_action.assert_called_once_with("POST", "process", json=order_data) - assert isinstance(order, Order) - - -def test_query(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.query(order_data) - - order_client.do_action.assert_called_once_with("POST", "query", json=order_data) - assert isinstance(order, Order) - - -def test_complete(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.complete(order_data) - - order_client.do_action.assert_called_once_with("POST", "complete", json=order_data) - assert isinstance(order, Order) - - -def test_fail(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.fail(order_data) - - order_client.do_action.assert_called_once_with("POST", "fail", json=order_data) - assert isinstance(order, Order) - - -def test_notify(order_client, order_response): - order_client.do_action.return_value = order_response - user_data = {"email": "user@example.com", "name": "John Doe"} - - order_client.notify(user_data) - - order_client.do_action.assert_called_once_with("POST", "notify", json=user_data) - - -def test_template(order_client): - template_response = httpx.Response( - status_code=200, - headers={"content-type": "text/markdown"}, - content="# Order Template\n\nThis is a markdown template.", - request=httpx.Request("GET", "https://api.example.com/orders/template"), - ) - order_client.do_action.return_value = template_response - - markdown_template = order_client.template() - - order_client.do_action.assert_called_once_with("GET", "template") - assert markdown_template == "# Order Template\n\nThis is a markdown template." diff --git a/tests/test_mpt.py b/tests/test_mpt.py index 4ecb9c4..bc6ee6a 100644 --- a/tests/test_mpt.py +++ b/tests/test_mpt.py @@ -1,31 +1,40 @@ -from unittest.mock import Mock +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient +from mpt_api_client.resources import AsyncCommerce, Commerce +from tests.conftest import API_TOKEN, API_URL -from mpt_api_client.mptclient import MPTClient -from mpt_api_client.resources import OrderCollectionClientBase +def test_mpt_client() -> None: + mpt = MPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) + commerce = mpt.commerce -def test_mapped_module() -> None: - mock_registry = Mock() - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key", registry=mock_registry) + assert isinstance(mpt, MPTClient) + assert isinstance(commerce, Commerce) - mpt.orders # noqa: B018 - mock_registry.get.assert_called_once_with("orders") +def test_mpt_client_env(monkeypatch): + monkeypatch.setenv("MPT_URL", API_URL) + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + mpt = MPTClient() -def test_not_mapped_module() -> None: - mock_registry = Mock() - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key", registry=mock_registry) + assert isinstance(mpt, MPTClient) + assert isinstance(mpt.http_client, HTTPClient) - mpt.non_existing_module # noqa: B018 - mock_registry.get.assert_called_once_with("non_existing_module") +def test_async_mpt_client() -> None: + mpt = AsyncMPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) + commerce = mpt.commerce + assert isinstance(mpt, AsyncMPTClient) + assert isinstance(commerce, AsyncCommerce) -def test_subclient_orders_module(): - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key") - orders_client = mpt.commerce.orders +def test_async_mpt_client_env(monkeypatch): + monkeypatch.setenv("MPT_URL", API_URL) + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) - assert isinstance(orders_client, OrderCollectionClientBase) - assert orders_client.http_client == mpt.http_client + mpt = AsyncMPTClient() + + assert isinstance(mpt, AsyncMPTClient) + assert isinstance(mpt.http_client, AsyncHTTPClient) diff --git a/tests/test_registry.py b/tests/test_registry.py deleted file mode 100644 index 3714380..0000000 --- a/tests/test_registry.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest - -from mpt_api_client.http.collection import CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Resource -from mpt_api_client.registry import Registry - - -class DummyResource(Resource): - """Dummy resource for testing.""" - - -class DummyCollectionClientBase(CollectionClientBase): - _endpoint = "/api/v1/dummy" - _resource_class = DummyResource - - -def test_register_collection_client_successfully(): - registry = Registry() - keyname = "test_collection" - - registry.register(keyname, DummyCollectionClientBase) - - assert keyname in registry.items - assert registry.items[keyname] == DummyCollectionClientBase - assert registry.get(keyname) == DummyCollectionClientBase - - -def test_get_registered_client_successfully(): - registry = Registry() - keyname = "orders" - - registry.register(keyname, DummyCollectionClientBase) - - retrieved_client = registry.get(keyname) - - assert retrieved_client == DummyCollectionClientBase - - -def test_get_raise_exception(): - registry = Registry() - unregistered_keyname = "nonexistent_client" - - with pytest.raises( - KeyError, match="No collection client registered with keyname: nonexistent_client" - ): - registry.get(unregistered_keyname) - - -def test_list_keys(): - registry = Registry() - expected_keys = ["orders", "customers", "products"] - - for keyname in expected_keys: - registry.register(keyname, DummyCollectionClientBase) - - registry_keys = registry.list_keys() - - assert sorted(registry_keys) == sorted(expected_keys) - assert len(registry_keys) == 3 - - -def test_registry_as_decorator(): - registry = Registry() - - @registry("test_call") - class TestCallClientBase( # noqa: WPS431 - CollectionClientBase[DummyResource, ResourceBaseClient[DummyResource]] - ): - _endpoint = "/api/v1/test-call" - _resource_class = DummyResource - - registered_client = registry.get("test_call") - - assert registered_client == TestCallClientBase