From e0c2e00ef196f5a397ed8fae44b87bc01f9db853 Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Fri, 21 Aug 2026 16:08:09 +0100 Subject: [PATCH] feat(exchange): add the exchange pairs service The /public/v1/exchange/pairs collection endpoint had no service class, so currency pairs were unreachable from both the sync and async clients. Add PairsService and AsyncPairsService with the Pair model, and expose them as the `pairs` property on Exchange and AsyncExchange. Mixins follow what the OpenAPI spec documents for this endpoint: a GET collection (CollectionMixin) and a GET by id on /pairs/{id} (GetMixin). The collection-level bulk POST, PUT and DELETE operations take a PairBulkData envelope and have no matching mixin or precedent in this client, so they are left out of this change. Streaming support is tracked separately in MPT-24241. MPT-24264 Co-Authored-By: Claude Opus 5 --- mpt_api_client/resources/exchange/exchange.py | 11 ++ mpt_api_client/resources/exchange/pairs.py | 69 ++++++++ .../unit/resources/exchange/test_exchange.py | 15 ++ tests/unit/resources/exchange/test_pairs.py | 167 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 mpt_api_client/resources/exchange/pairs.py create mode 100644 tests/unit/resources/exchange/test_pairs.py diff --git a/mpt_api_client/resources/exchange/exchange.py b/mpt_api_client/resources/exchange/exchange.py index fff7e46f..63234d88 100644 --- a/mpt_api_client/resources/exchange/exchange.py +++ b/mpt_api_client/resources/exchange/exchange.py @@ -3,6 +3,7 @@ AsyncCurrenciesService, CurrenciesService, ) +from mpt_api_client.resources.exchange.pairs import AsyncPairsService, PairsService class Exchange: @@ -16,6 +17,11 @@ def currencies(self) -> CurrenciesService: """Currencies service.""" return CurrenciesService(http_client=self.http_client) + @property + def pairs(self) -> PairsService: + """Pairs service.""" + return PairsService(http_client=self.http_client) + class AsyncExchange: """Exchange MPT API Module.""" @@ -27,3 +33,8 @@ def __init__(self, *, http_client: AsyncHTTPClient): def currencies(self) -> AsyncCurrenciesService: """Currencies service.""" return AsyncCurrenciesService(http_client=self.http_client) + + @property + def pairs(self) -> AsyncPairsService: + """Pairs service.""" + return AsyncPairsService(http_client=self.http_client) diff --git a/mpt_api_client/resources/exchange/pairs.py b/mpt_api_client/resources/exchange/pairs.py new file mode 100644 index 00000000..16234a63 --- /dev/null +++ b/mpt_api_client/resources/exchange/pairs.py @@ -0,0 +1,69 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncGetMixin, + CollectionMixin, + GetMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel + + +class Pair(Model): + """Exchange currency pair resource. + + Attributes: + name: Pair name. + external_id: External identifier of the pair. + notes: Free-form notes for the pair. + primary: Whether the pair is the primary one for its currencies. + reverse: Reference to the pair holding the reverse conversion. + source_currency: Currency converted from. + destination_currency: Currency converted to. + latest_rate: Most recent rate recorded for the pair. + agreements: Number of agreements using the pair. + rates: Number of rates recorded for the pair. + status: Current status of the pair. + revision: Revision number. + audit: Audit information (created, updated events). + """ + + name: str | None + external_id: str | None + notes: str | None + primary: bool | None + reverse: BaseModel | None + source_currency: BaseModel | None + destination_currency: BaseModel | None + latest_rate: BaseModel | None + agreements: int | None + rates: int | None + status: str | None + revision: int | None + audit: BaseModel | None + + +class PairsServiceConfig: + """Exchange Pairs service configuration.""" + + _endpoint = "/public/v1/exchange/pairs" + _model_class = Pair + _collection_key = "data" + + +class PairsService( + GetMixin[Pair], + CollectionMixin[Pair], + Service[Pair], + PairsServiceConfig, +): + """Exchange Pairs service.""" + + +class AsyncPairsService( + AsyncGetMixin[Pair], + AsyncCollectionMixin[Pair], + AsyncService[Pair], + PairsServiceConfig, +): + """Async Exchange Pairs service.""" diff --git a/tests/unit/resources/exchange/test_exchange.py b/tests/unit/resources/exchange/test_exchange.py index c77dbe79..ab3c15f3 100644 --- a/tests/unit/resources/exchange/test_exchange.py +++ b/tests/unit/resources/exchange/test_exchange.py @@ -5,6 +5,7 @@ CurrenciesService, ) from mpt_api_client.resources.exchange.exchange import AsyncExchange, Exchange +from mpt_api_client.resources.exchange.pairs import AsyncPairsService, PairsService @pytest.fixture @@ -41,3 +42,17 @@ def test_async_exchange_currencies_property(async_exchange): assert isinstance(result, AsyncCurrenciesService) assert result.http_client is async_exchange.http_client + + +def test_exchange_pairs_property(exchange): + result = exchange.pairs + + assert isinstance(result, PairsService) + assert result.http_client is exchange.http_client + + +def test_async_exchange_pairs_property(async_exchange): + result = async_exchange.pairs + + assert isinstance(result, AsyncPairsService) + assert result.http_client is async_exchange.http_client diff --git a/tests/unit/resources/exchange/test_pairs.py b/tests/unit/resources/exchange/test_pairs.py new file mode 100644 index 00000000..ce36ddfd --- /dev/null +++ b/tests/unit/resources/exchange/test_pairs.py @@ -0,0 +1,167 @@ +import httpx +import pytest +import respx + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.exchange.pairs import AsyncPairsService, Pair, PairsService + + +@pytest.fixture +def pairs_service(http_client): + return PairsService(http_client=http_client) + + +@pytest.fixture +def async_pairs_service(async_http_client): + return AsyncPairsService(http_client=async_http_client) + + +@pytest.fixture +def pair_data(): + return { + "id": "PAI-001", + "name": "USD/EUR", + "externalId": "EXT-001", + "notes": "Primary conversion pair", + "primary": True, + "reverse": {"id": "PAI-002", "name": "EUR/USD"}, + "sourceCurrency": {"id": "CUR-001", "code": "USD"}, + "destinationCurrency": {"id": "CUR-002", "code": "EUR"}, + "latestRate": {"id": "RAT-001", "value": 0.92}, + "agreements": 3, + "rates": 12, + "status": "Active", + "revision": 1, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +@pytest.mark.parametrize("method", ["get", "fetch_page", "fetch_one", "iterate"]) +def test_mixins_present(pairs_service, method): + result = hasattr(pairs_service, method) + + assert result is True + + +@pytest.mark.parametrize("method", ["get", "fetch_page", "fetch_one", "iterate"]) +def test_async_mixins_present(async_pairs_service, method): + result = hasattr(async_pairs_service, method) + + assert result is True + + +def test_pairs_service_endpoint(pairs_service): + result = pairs_service.build_path() + + assert result == "/public/v1/exchange/pairs" + + +def test_async_pairs_service_endpoint(async_pairs_service): + result = async_pairs_service.build_path() + + assert result == "/public/v1/exchange/pairs" + + +def test_pair_primitive_fields(pair_data): + result = Pair(pair_data) + + assert result.to_dict() == pair_data + + +@pytest.mark.parametrize("field", ["name", "external_id", "notes", "status"]) +def test_pair_string_fields(pair_data, field): + result = Pair(pair_data) + + assert isinstance(getattr(result, field), str) + + +@pytest.mark.parametrize("field", ["agreements", "rates", "revision"]) +def test_pair_integer_fields(pair_data, field): + result = Pair(pair_data) + + assert isinstance(getattr(result, field), int) + + +def test_pair_primary_field(pair_data): + result = Pair(pair_data) + + assert result.primary is True + + +@pytest.mark.parametrize( + "field", + ["reverse", "source_currency", "destination_currency", "latest_rate", "audit"], +) +def test_pair_nested_model_fields(pair_data, field): + result = Pair(pair_data) + + assert isinstance(getattr(result, field), BaseModel) + + +@pytest.mark.parametrize("field", ["name", "external_id", "status", "audit"]) +def test_pair_optional_fields_absent(field): + result = Pair({"id": "PAI-001"}) + + assert not hasattr(result, field) + + +def test_pair_id_present(): + result = Pair({"id": "PAI-001"}) + + assert result.id == "PAI-001" + + +def test_get_pair(pairs_service): + pair_id = "PAI-001" + expected_response = {"id": pair_id, "name": "USD/EUR", "primary": True} + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/pairs/{pair_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = pairs_service.get(pair_id) + + assert result.to_dict() == expected_response + + +async def test_async_get_pair(async_pairs_service): + pair_id = "PAI-001" + expected_response = {"id": pair_id, "name": "USD/EUR", "primary": True} + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/pairs/{pair_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = await async_pairs_service.get(pair_id) + + assert result.to_dict() == expected_response + + +def test_fetch_page_pairs(pairs_service): + expected_pairs = [{"id": "PAI-001", "name": "USD/EUR"}, {"id": "PAI-002", "name": "EUR/USD"}] + with respx.mock: + respx.get("https://api.example.com/public/v1/exchange/pairs").mock( + return_value=httpx.Response( + httpx.codes.OK, + json={"data": expected_pairs, "$meta": {"pagination": {"total": 2}}}, + ) + ) + + result = pairs_service.fetch_page() + + assert [pair.to_dict() for pair in result] == expected_pairs + + +async def test_async_fetch_page_pairs(async_pairs_service): + expected_pairs = [{"id": "PAI-001", "name": "USD/EUR"}, {"id": "PAI-002", "name": "EUR/USD"}] + with respx.mock: + respx.get("https://api.example.com/public/v1/exchange/pairs").mock( + return_value=httpx.Response( + httpx.codes.OK, + json={"data": expected_pairs, "$meta": {"pagination": {"total": 2}}}, + ) + ) + + result = await async_pairs_service.fetch_page() + + assert [pair.to_dict() for pair in result] == expected_pairs