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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions mpt_api_client/http/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

import httpx


Expand All @@ -7,12 +9,26 @@ class MPTClient(httpx.Client):
def __init__(
self,
*,
base_url: str,
api_token: str,
base_url: str | None = None,
api_token: str | None = None,
timeout: float = 5.0,
retries: int = 0,
):
self.api_token = api_token
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}",
Expand Down
199 changes: 199 additions & 0 deletions mpt_api_client/http/collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import copy
from abc import ABC
from collections.abc import Iterator
from typing import Any, Self

import httpx

from mpt_api_client.http.client import MPTClient
from mpt_api_client.models import Collection, Resource
from mpt_api_client.rql.query_builder import RQLQuery


class CollectionBaseClient[ResourceType: Resource](ABC): # noqa: WPS214
"""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)

"""

_endpoint: str
_resource_class: type[Resource]
_collection_class: type[Collection[Resource]]

def __init__(
self,
query_rql: RQLQuery | None = None,
client: MPTClient | None = None,
) -> None:
self.mpt_client = client or MPTClient()
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: "CollectionBaseClient[ResourceType]") -> Self:
"""Create a copy of collection client for immutable operations.

Returns:
New collection client with same settings.
"""
new_collection = cls(
client=collection_client.mpt_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

def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceType]:
"""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) -> ResourceType:
"""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[ResourceType] = 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) -> Iterator[ResourceType]:
"""Iterate over all resources, yielding GenericResource objects.

Returns:
Iterator of resources.
"""
offset = 0
limit = 100 # Default page size

while True:
response = self._fetch_page_as_response(limit=limit, offset=offset)
items_collection: Collection[ResourceType] = Collection.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 create(self, resource_data: dict[str, Any]) -> ResourceType:
"""Create a new resource using `POST /endpoint`.

Returns:
New resource created.
"""
response = self.mpt_client.post(self._endpoint, json=resource_data)
response.raise_for_status()

return self._resource_class.from_response(response) # type: ignore[return-value]

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.mpt_client.get(self.build_url(pagination_params))
response.raise_for_status()

return response
8 changes: 8 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ per-file-ignores =
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
tests/*:
# Allow magic strings
WPS432
Expand Down
19 changes: 19 additions & 0 deletions tests/http/collection/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import pytest

from mpt_api_client.http.collection import CollectionBaseClient
from mpt_api_client.models import Collection, Resource


class DummyResource(Resource):
"""Dummy resource for testing."""


class DummyCollectionClient(CollectionBaseClient[DummyResource]):
_endpoint = "/api/v1/test"
_resource_class = DummyResource
_collection_class = Collection[DummyResource]


@pytest.fixture
def collection_client(mpt_client):
return DummyCollectionClient(client=mpt_client)
69 changes: 69 additions & 0 deletions tests/http/collection/test_collection_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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)"
)
24 changes: 24 additions & 0 deletions tests/http/collection/test_collection_client_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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
Loading