diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index a938f53..3d2ee7d 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -1,3 +1,5 @@ +import os + import httpx @@ -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}", diff --git a/mpt_api_client/http/collection.py b/mpt_api_client/http/collection.py new file mode 100644 index 0000000..6750df8 --- /dev/null +++ b/mpt_api_client/http/collection.py @@ -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 diff --git a/setup.cfg b/setup.cfg index d3058d4..6ca09d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/tests/http/collection/conftest.py b/tests/http/collection/conftest.py new file mode 100644 index 0000000..f4822e4 --- /dev/null +++ b/tests/http/collection/conftest.py @@ -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) diff --git a/tests/http/collection/test_collection_client.py b/tests/http/collection/test_collection_client.py new file mode 100644 index 0000000..9001609 --- /dev/null +++ b/tests/http/collection/test_collection_client.py @@ -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)" + ) diff --git a/tests/http/collection/test_collection_client_create.py b/tests/http/collection/test_collection_client_create.py new file mode 100644 index 0000000..ca207e2 --- /dev/null +++ b/tests/http/collection/test_collection_client_create.py @@ -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 diff --git a/tests/http/collection/test_collection_client_fetch.py b/tests/http/collection/test_collection_client_fetch.py new file mode 100644 index 0000000..93d8e1a --- /dev/null +++ b/tests/http/collection/test_collection_client_fetch.py @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..37a03a4 --- /dev/null +++ b/tests/http/collection/test_collection_client_init.py @@ -0,0 +1,41 @@ +import pytest + +from mpt_api_client.http.client import MPTClient +from mpt_api_client.models import Resource +from mpt_api_client.rql.query_builder import RQLQuery +from tests.http.collection.conftest import DummyCollectionClient + + +class DummyResource(Resource): + """Dummy resource for testing.""" + + +@pytest.fixture +def mock_mpt_client(api_url, api_token): + return MPTClient(base_url=api_url, api_token=api_token) + + +@pytest.fixture +def sample_rql_query(): + return RQLQuery(status="active") + + +def test_init_defaults(mpt_client): + collection_client = DummyCollectionClient(client=mpt_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(mpt_client, sample_rql_query): + collection_client = DummyCollectionClient( + client=mpt_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 new file mode 100644 index 0000000..1b62879 --- /dev/null +++ b/tests/http/collection/test_collection_client_iterate.py @@ -0,0 +1,234 @@ +import httpx +import pytest +import respx + +from mpt_api_client.rql import RQLQuery + + +@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"}, + ] + }, + ) + + +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": 100, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 100, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + resources = list(collection_client.iterate()) + + 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()) + + 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/conftest.py b/tests/http/conftest.py new file mode 100644 index 0000000..80aca9e --- /dev/null +++ b/tests/http/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from mpt_api_client.http.client import MPTClient + +API_TOKEN = "test-token" +API_URL = "https://api.example.com" + + +@pytest.fixture +def mpt_client(): + return MPTClient(base_url=API_URL, api_token=API_TOKEN) diff --git a/tests/http/test_client.py b/tests/http/test_client.py index 0a09cc1..b372cf6 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -3,25 +3,37 @@ from httpx import ConnectTimeout, Response, codes from mpt_api_client.http.client import MPTClient - -API_TOKEN = "test-token" -API_URL = "https://api.example.com" - - -@pytest.fixture -def mpt_client(): - return MPTClient(base_url=API_URL, api_token=API_TOKEN) +from tests.http.conftest import API_TOKEN, API_URL def test_mpt_client_initialization(): client = MPTClient(base_url=API_URL, api_token=API_TOKEN) - assert client.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): + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_URL", API_URL) + + client = MPTClient() + + assert client.base_url == API_URL + assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" + + +def test_mpt_client_without_token(): + with pytest.raises(ValueError): + MPTClient(base_url=API_URL) + + +def test_mpt_client_without_url(): + with pytest.raises(ValueError): + MPTClient(api_token=API_TOKEN) + + @respx.mock def test_mock_call_success(mpt_client): success_route = respx.get(f"{API_URL}/").mock(