From 69c18be2d6e1baff11a43d5906e465f7455d8a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susana=20V=C3=A1zquez?= <3016283+svazquezco@users.noreply.github.com> Date: Wed, 20 May 2026 12:52:27 +0200 Subject: [PATCH] feat: add offset pagination support --- docs/sdk_usage/api.md | 30 ++++-- mock_app/api/api_routes.py | 14 ++- mock_app/api/routes/api.py | 7 +- mock_app/docs/example.http | 9 +- mock_app/mocks/api_service.py | 12 ++- mock_app/sync/agreements.py | 16 +++- mpt_extension_sdk/api/builders/execution.py | 2 +- mpt_extension_sdk/api/pagination.py | 96 ++++--------------- mpt_extension_sdk/api/responses.py | 30 +++--- .../services/mpt_api_service/agreement.py | 11 ++- .../services/mpt_api_service/base.py | 39 +++++--- .../services/mpt_api_service/product.py | 7 +- tests/api/builders/test_api.py | 25 ++--- tests/api/test_pagination.py | 54 +++-------- tests/api/test_responses.py | 13 +-- tests/services/mpt_api_service/test_base.py | 55 ++++++----- .../services/mpt_api_service/test_product.py | 15 +-- 17 files changed, 210 insertions(+), 225 deletions(-) diff --git a/docs/sdk_usage/api.md b/docs/sdk_usage/api.md index bcd90f3..2c835ed 100644 --- a/docs/sdk_usage/api.md +++ b/docs/sdk_usage/api.md @@ -141,8 +141,7 @@ body, the SDK serializes the result using the standard JSON envelope: ```json { "data": {}, - "meta": {}, - "links": {} + "$meta": {} } ``` @@ -154,9 +153,26 @@ Use the response helper that matches the endpoint semantics: - `APIResponse.paginated(PaginatedResult.from_pagination(...))` - `APIResponse.no_content()` -For page-based collection responses, use `ctx.request.pagination` and -`PaginatedResult`. The SDK parses `page` and `page_size` lazily from the query -string and builds `meta` plus pagination links from the current request URL. +For offset-based collection responses, use `ctx.request.pagination` and +`PaginatedResult`. The SDK parses `offset` and `limit` lazily from the query +string and serializes pagination under `$meta.pagination`. Missing values +default to `offset=0` and `limit=100`; `limit` may be `0` for count-only +requests and cannot be greater than `100`. + +Paginated responses use this shape: + +```json +{ + "$meta": { + "pagination": { + "offset": 0, + "limit": 100, + "total": 250 + } + }, + "data": [] +} +``` ```python from mpt_extension_sdk.api import APIContext, APIResponse, PaginatedResult @@ -166,8 +182,8 @@ from mpt_extension_sdk.api import APIContext, APIResponse, PaginatedResult async def list_orders(ctx: APIContext) -> APIResponse: pagination = ctx.request.pagination result = await OrderService().list_orders( - page=pagination.page, - page_size=pagination.page_size, + offset=pagination.offset, + limit=pagination.limit, ) return APIResponse.paginated( PaginatedResult.from_pagination( diff --git a/mock_app/api/api_routes.py b/mock_app/api/api_routes.py index 2bd45d2..81055ba 100644 --- a/mock_app/api/api_routes.py +++ b/mock_app/api/api_routes.py @@ -19,9 +19,17 @@ async def handle_get_agreement(agreement_id: str, ctx: APIContext) -> APIRespons @api_router.get("/agreements", name="agreements-list") async def handle_get_agreements(ctx: APIContext) -> APIResponse: """Return paginated mock agreements.""" - agreements = await ctx.mpt_api_service.agreements.get_all(batch_size=3) # type: ignore[attr-defined] - result = PaginatedResult.from_pagination(ctx.request.pagination, payload=agreements, total=10) - return APIResponse.paginated(result) + pagination = ctx.request.pagination + page = await ctx.mpt_api_service.agreements.get_all( + offset=pagination.offset, limit=pagination.limit + ) + return APIResponse.paginated( + PaginatedResult.from_pagination( + pagination, + payload=page.resources, + total=page.total, + ) + ) @api_router.post("/agreements", name="agreements-create", body_validator=AgreementSchema) diff --git a/mock_app/api/routes/api.py b/mock_app/api/routes/api.py index 2bd45d2..839064f 100644 --- a/mock_app/api/routes/api.py +++ b/mock_app/api/routes/api.py @@ -19,8 +19,11 @@ async def handle_get_agreement(agreement_id: str, ctx: APIContext) -> APIRespons @api_router.get("/agreements", name="agreements-list") async def handle_get_agreements(ctx: APIContext) -> APIResponse: """Return paginated mock agreements.""" - agreements = await ctx.mpt_api_service.agreements.get_all(batch_size=3) # type: ignore[attr-defined] - result = PaginatedResult.from_pagination(ctx.request.pagination, payload=agreements, total=10) + pagination = ctx.request.pagination + page = await ctx.mpt_api_service.agreements.get_all( + offset=pagination.offset, limit=pagination.limit + ) + result = PaginatedResult.from_pagination(pagination, payload=page.resources, total=page.total) return APIResponse.paginated(result) diff --git a/mock_app/docs/example.http b/mock_app/docs/example.http index ad539f5..b2f8345 100644 --- a/mock_app/docs/example.http +++ b/mock_app/docs/example.http @@ -54,7 +54,14 @@ Authorization: Bearer {{auth_token}} ### # [API][agreements-list] (expected 200 Ok - Paginated) -GET http://localhost:8080/api/v2/agreements?page=2&page_size=3 +GET http://localhost:8080/api/v2/agreements?offset=2&limit=3 +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +### + +# [API][agreements-list] (expected 200 Ok - count-based pagination) +GET http://localhost:8080/api/v2/agreements?limit=0 Content-Type: application/json Authorization: Bearer {{auth_token}} diff --git a/mock_app/mocks/api_service.py b/mock_app/mocks/api_service.py index ce5389f..87ed997 100644 --- a/mock_app/mocks/api_service.py +++ b/mock_app/mocks/api_service.py @@ -7,6 +7,7 @@ from mpt_extension_sdk.services.api_client_v2.mpt_api_client import AsyncMPTClient from mpt_extension_sdk.services.mpt_api_service import MPTAPIService from mpt_extension_sdk.services.mpt_api_service.agreement import AgreementService +from mpt_extension_sdk.services.mpt_api_service.base import PaginatedCollection from mpt_extension_sdk.services.mpt_api_service.installation import InstallationService from mpt_extension_sdk.services.mpt_api_service.order import OrderService @@ -28,9 +29,11 @@ async def create(self, agreement: Mapping[str, Any] | BaseModel) -> Agreement: """Create an agreement.""" return Agreement.from_payload(agreement) - async def get_all(self, batch_size: int = 100) -> list[Agreement]: - """Get all agreements.""" - return [ + @override + async def get_all(self, offset: int = 0, limit: int = 100) -> PaginatedCollection[Agreement]: + """Get agreements using offset pagination.""" + total = 10 + agreements = [ Agreement.from_payload({ "id": f"AGR-{ind}", "name": "Test Agreement", @@ -39,8 +42,9 @@ async def get_all(self, batch_size: int = 100) -> list[Agreement]: "parameters": {}, "product": {"id": f"PROD-11{ind}", "name": "Test Product"}, }) - for ind in range(batch_size) + for ind in range(offset, min(offset + limit, total)) ] + return PaginatedCollection(limit=limit, offset=offset, resources=agreements, total=total) @override async def get_by_id(self, agreement_id: str) -> Agreement: diff --git a/mock_app/sync/agreements.py b/mock_app/sync/agreements.py index 6808328..cad72dd 100644 --- a/mock_app/sync/agreements.py +++ b/mock_app/sync/agreements.py @@ -9,11 +9,17 @@ class SyncAgreements: async def execute(self, ctx: APIContext) -> None: """Sync agreements.""" ctx.logger.info("Sync agreements") - agreements = await ctx.mpt_api_service.agreements.get_all(batch_size=5) # type: ignore[attr-defined] - for agreement in agreements: - ctx.logger.info("Syncing agreement %s", agreement.id) - self._sync_agreement(agreement.id) - ctx.logger.info("Agreement %s synced", agreement.id) + offset = 0 + limit = 5 + while True: + page = await ctx.mpt_api_service.agreements.get_all(offset=offset, limit=limit) + for agreement in page.resources: + ctx.logger.info("Syncing agreement %s", agreement.id) + self._sync_agreement(agreement.id) + ctx.logger.info("Agreement %s synced", agreement.id) + offset += limit + if offset >= page.total or not page.resources: + break @trace_span( "sync_agreement", diff --git a/mpt_extension_sdk/api/builders/execution.py b/mpt_extension_sdk/api/builders/execution.py index 6232dbc..a2564d3 100644 --- a/mpt_extension_sdk/api/builders/execution.py +++ b/mpt_extension_sdk/api/builders/execution.py @@ -112,7 +112,7 @@ async def _execute_handler( request=request, correlation_id=correlation_id_ctx.get(), ) - return response.to_http_response(request_url=str(request.url)) + return response.to_http_response() async def _execute_in_span( self, *, context: APIContext, request: Request, span: Span diff --git a/mpt_extension_sdk/api/pagination.py b/mpt_extension_sdk/api/pagination.py index 1d949aa..fdabc00 100644 --- a/mpt_extension_sdk/api/pagination.py +++ b/mpt_extension_sdk/api/pagination.py @@ -1,14 +1,12 @@ from dataclasses import dataclass -from math import ceil from typing import Any, Protocol, Self -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from mpt_extension_sdk.api.errors import ValidationError from mpt_extension_sdk.api.models.errors import ErrorDetail -DEFAULT_PAGE = 1 -DEFAULT_PAGE_SIZE = 20 -MAX_PAGE_SIZE = 500 +DEFAULT_OFFSET = 0 +DEFAULT_LIMIT = 100 +MAX_LIMIT = 100 class QueryParameters(Protocol): @@ -23,33 +21,33 @@ def get(self, key: str, default: Any = None) -> str | Any: class Pagination: """Pagination parameters parsed from an authenticated API request.""" - page: int = DEFAULT_PAGE - page_size: int = DEFAULT_PAGE_SIZE + offset: int = DEFAULT_OFFSET + limit: int = DEFAULT_LIMIT @classmethod def from_query(cls, query: QueryParameters) -> Self: """Build pagination parameters from request query parameters.""" return cls( - page=cls._parse_positive_int(query, "page", DEFAULT_PAGE), - page_size=cls._parse_page_size(query), + offset=cls._parse_non_negative_int(query, "offset", DEFAULT_OFFSET), + limit=cls._parse_limit(query), ) @classmethod - def _parse_page_size(cls, query: QueryParameters) -> int: - page_size = cls._parse_positive_int(query, "page_size", DEFAULT_PAGE_SIZE) - if page_size > MAX_PAGE_SIZE: + def _parse_limit(cls, query: QueryParameters) -> int: + limit = cls._parse_non_negative_int(query, "limit", DEFAULT_LIMIT) + if limit > MAX_LIMIT: raise ValidationError( errors=[ ErrorDetail( - pointer="#/page_size", - detail=f"Value must be less than or equal to {MAX_PAGE_SIZE}", + pointer="#/limit", + detail=f"Value must be less than or equal to {MAX_LIMIT}", ) ] ) - return page_size + return limit @classmethod - def _parse_positive_int(cls, query: QueryParameters, name: str, default: int) -> int: + def _parse_non_negative_int(cls, query: QueryParameters, name: str, default: int) -> int: raw_int_value = query.get(name) if raw_int_value is None: return default @@ -61,11 +59,11 @@ def _parse_positive_int(cls, query: QueryParameters, name: str, default: int) -> errors=[ErrorDetail(pointer=f"#/{name}", detail="Value must be an integer")] ) from error - if int_value < 1: + if int_value < 0: raise ValidationError( errors=[ ErrorDetail( - pointer=f"#/{name}", detail="Value must be greater than or equal to 1" + pointer=f"#/{name}", detail="Value must be greater than or equal to 0" ) ] ) @@ -78,16 +76,8 @@ class PaginatedResult: payload: Any total: int - page: int - page_size: int - - @property - def total_pages(self) -> int: - """Return the total number of pages.""" - if self.total <= 0: - return 0 - - return ceil(self.total / self.page_size) + offset: int + limit: int @classmethod def from_pagination(cls, pagination: Pagination, *, payload: Any, total: int) -> Self: @@ -95,52 +85,6 @@ def from_pagination(cls, pagination: Pagination, *, payload: Any, total: int) -> return cls( payload=payload, total=total, - page=pagination.page, - page_size=pagination.page_size, + offset=pagination.offset, + limit=pagination.limit, ) - - -class PaginationLinksBuilder: - """Build standard pagination links from the current request URL.""" - - @classmethod - def build(cls, request_url: str, result: PaginatedResult) -> dict[str, str | None]: - """Build all standard pagination links for a paginated result.""" - total_pages = result.total_pages - last_page = max(total_pages, 1) - previous_page = result.page - 1 if result.page > 1 else None - next_page = result.page + 1 if total_pages and result.page < total_pages else None - - return { - "self": cls.replace_page(request_url, result.page, result.page_size), - "first": cls.replace_page(request_url, 1, result.page_size), - "prev": ( - None - if previous_page is None - else cls.replace_page(request_url, previous_page, result.page_size) - ), - "next": ( - None - if next_page is None - else cls.replace_page(request_url, next_page, result.page_size) - ), - "last": cls.replace_page(request_url, last_page, result.page_size), - } - - @classmethod - def replace_page(cls, request_url: str, page: int, page_size: int) -> str: - """Return the request URL with normalized page and page_size query parameters.""" - parts = urlsplit(request_url) - query_items = [ - (key, query_item) - for key, query_item in parse_qsl(parts.query, keep_blank_values=True) - if key not in {"page", "page_size"} - ] - query_items.extend([("page", str(page)), ("page_size", str(page_size))]) # noqa: WPS221 - return urlunsplit(( - parts.scheme, - parts.netloc, - parts.path, - urlencode(query_items), - parts.fragment, - )) diff --git a/mpt_extension_sdk/api/responses.py b/mpt_extension_sdk/api/responses.py index 92548a6..6a7dcf7 100644 --- a/mpt_extension_sdk/api/responses.py +++ b/mpt_extension_sdk/api/responses.py @@ -6,16 +6,15 @@ from fastapi.responses import JSONResponse, Response from mpt_extension_sdk.api.models.base import APIBaseModel -from mpt_extension_sdk.api.pagination import PaginatedResult, PaginationLinksBuilder +from mpt_extension_sdk.api.pagination import PaginatedResult class Meta(APIBaseModel): """Meta model.""" total: int | None = None - page: int | None = None - page_size: int | None = None - total_pages: int | None = None + offset: int | None = None + limit: int | None = None class Links(APIBaseModel): @@ -91,7 +90,7 @@ def paginated(cls, result: PaginatedResult) -> Self: """Return a 200 OK paginated response.""" return cls(status_code=HTTPStatus.OK, payload=result.payload, paginated_result=result) - def to_http_response(self, *, request_url: str | None = None) -> Response: + def to_http_response(self) -> Response: """Convert the SDK response envelope to a FastAPI response.""" if not self.has_body: return Response(status_code=self.status_code) @@ -99,21 +98,16 @@ def to_http_response(self, *, request_url: str | None = None) -> Response: payload: dict[str, Any] = {"data": self.payload} if self.paginated_result is None: if self.meta is not None: - payload["meta"] = self.meta + payload["$meta"] = self.meta if self.links is not None: payload["links"] = self.links else: - if not request_url: - raise ValueError("request_url is required for paginated responses") - - payload["meta"] = Meta( - total=self.paginated_result.total, - page=self.paginated_result.page, - page_size=self.paginated_result.page_size, - total_pages=self.paginated_result.total_pages, - ) - payload["links"] = Links( - **PaginationLinksBuilder.build(request_url, self.paginated_result) - ) + payload["$meta"] = { + "pagination": { + "offset": self.paginated_result.offset, + "limit": self.paginated_result.limit, + "total": self.paginated_result.total, + } + } return JSONResponse(content=jsonable_encoder(payload), status_code=self.status_code) diff --git a/mpt_extension_sdk/services/mpt_api_service/agreement.py b/mpt_extension_sdk/services/mpt_api_service/agreement.py index fec17a3..85fe7b5 100644 --- a/mpt_extension_sdk/services/mpt_api_service/agreement.py +++ b/mpt_extension_sdk/services/mpt_api_service/agreement.py @@ -4,7 +4,7 @@ from mpt_extension_sdk.models import Agreement from mpt_extension_sdk.models.base import BaseModel -from mpt_extension_sdk.services.mpt_api_service.base import BaseService +from mpt_extension_sdk.services.mpt_api_service.base import BaseService, PaginatedCollection logger = logging.getLogger(__name__) @@ -12,6 +12,15 @@ class AgreementService(BaseService[Agreement]): """Agreements service.""" + async def get_all(self, offset: int = 0, limit: int = 100) -> PaginatedCollection[Agreement]: + """Fetch a page of agreements.""" + return await self._paginate( + self._client.commerce.agreements, + Agreement, + offset=offset, + limit=limit, + ) + async def get_by_id(self, agreement_id: str) -> Agreement: """Fetch an agreement.""" agreement = await self._client.commerce.agreements.get( diff --git a/mpt_extension_sdk/services/mpt_api_service/base.py b/mpt_extension_sdk/services/mpt_api_service/base.py index 34cb0b6..e624aeb 100644 --- a/mpt_extension_sdk/services/mpt_api_service/base.py +++ b/mpt_extension_sdk/services/mpt_api_service/base.py @@ -1,15 +1,24 @@ from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from mpt_extension_sdk.models.base import BaseModel from mpt_extension_sdk.services.api_client_v2.mpt_api_client import AsyncMPTClient +@dataclass(frozen=True) +class PaginatedCollection[Model: BaseModel]: + """Paginated collection returned by Marketplace services.""" + + limit: int + offset: int + resources: list[Model] + total: int + + class BaseService[Model: BaseModel]: """Base service class for all services.""" - _batch_size = 100 - def __init__(self, client: AsyncMPTClient) -> None: """Initialize service with an MPT client.""" self._client = client @@ -20,12 +29,20 @@ def _serialize_attributes(self, attributes: Mapping[str, Any] | BaseModel) -> di return attributes.to_dict() return dict(attributes) - async def _iterate_all( - self, collection: Any, model: type[Model], batch_size: int | None = None - ) -> list[Model]: - """Collect all resources from an iterable collection query.""" - effective_batch_size = self._batch_size if batch_size is None else batch_size - return [ - model.from_payload(element) - async for element in collection.iterate(batch_size=effective_batch_size) - ] + async def _paginate( + self, + collection: Any, + model: type[Model], + *, + offset: int = 0, + limit: int = 100, + ) -> PaginatedCollection[Model]: + """Fetch and serialize one page from a Marketplace collection.""" + page = await collection.fetch_page(offset=offset, limit=limit) + pagination = page.meta.pagination if page.meta else None + return PaginatedCollection( + limit=pagination.limit if pagination else limit, + offset=pagination.offset if pagination else offset, + resources=[model.from_payload(element) for element in page], + total=pagination.total if pagination else len(page), + ) diff --git a/mpt_extension_sdk/services/mpt_api_service/product.py b/mpt_extension_sdk/services/mpt_api_service/product.py index 1f94648..bdb997e 100644 --- a/mpt_extension_sdk/services/mpt_api_service/product.py +++ b/mpt_extension_sdk/services/mpt_api_service/product.py @@ -22,4 +22,9 @@ async def get_product_one_time_items_by_ids( & RQLQuery().id.in_(item_ids) # type: ignore[arg-type] & RQLQuery().n("terms.period").eq("one-time") ) - return await self._iterate_all(self._client.catalog.items.filter(query), ProductItem) + page = await self._paginate( + self._client.catalog.items.filter(query), + ProductItem, + limit=len(item_ids), + ) + return page.resources diff --git a/tests/api/builders/test_api.py b/tests/api/builders/test_api.py index c72109f..eddffd7 100644 --- a/tests/api/builders/test_api.py +++ b/tests/api/builders/test_api.py @@ -197,7 +197,7 @@ def wrapper(ctx, **kwargs): @pytest.fixture def invalid_pagination_handler(ok_response_factory): def wrapper(ctx): - return ok_response_factory(payload={"page": ctx.request.pagination.page}) + return ok_response_factory(payload={"offset": ctx.request.pagination.offset}) return wrapper @@ -674,22 +674,12 @@ def test_api_route_builds_paginated_response(api_route_dependencies, auth_header name="adobe-orders", ) - result = client.get( - "/adobe/orders?page=2&page_size=2&filter=open", - headers=auth_headers, - ) + result = client.get("/adobe/orders?offset=2&limit=2", headers=auth_headers) assert result.status_code == HTTPStatus.OK assert result.json() == { "data": [{"id": "ORD-1"}, {"id": "ORD-2"}], - "meta": {"total": 5, "page": 2, "page_size": 2, "total_pages": 3}, - "links": { - "self": "http://testserver/adobe/orders?filter=open&page=2&page_size=2", - "first": "http://testserver/adobe/orders?filter=open&page=1&page_size=2", - "prev": "http://testserver/adobe/orders?filter=open&page=1&page_size=2", - "next": "http://testserver/adobe/orders?filter=open&page=3&page_size=2", - "last": "http://testserver/adobe/orders?filter=open&page=3&page_size=2", - }, + "$meta": {"pagination": {"offset": 2, "limit": 2, "total": 5}}, } @@ -703,15 +693,12 @@ def test_api_route_maps_invalid_pagination_errors( name="adobe-orders", ) - result = client.get( - "/adobe/orders?page_size=999", - headers=auth_headers, - ) + result = client.get("/adobe/orders?limit=999", headers=auth_headers) assert result.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert result.json()["errors"] == [ { - "detail": "Value must be less than or equal to 500", - "pointer": "#/page_size", + "detail": "Value must be less than or equal to 100", + "pointer": "#/limit", } ] diff --git a/tests/api/test_pagination.py b/tests/api/test_pagination.py index f4e5b5d..1b75e51 100644 --- a/tests/api/test_pagination.py +++ b/tests/api/test_pagination.py @@ -1,69 +1,45 @@ import pytest from mpt_extension_sdk.api import PaginatedResult, Pagination, ValidationError -from mpt_extension_sdk.api.pagination import PaginationLinksBuilder TOTAL_ITEMS = 25 -TOO_LARGE_PAGE_SIZE = "501" +TOO_LARGE_LIMIT = "101" def test_paginated_result_from_pagination(): - pagination = Pagination(page=2, page_size=5) + pagination = Pagination(offset=25, limit=5) result = PaginatedResult.from_pagination(pagination, payload=["item"], total=TOTAL_ITEMS) assert result.payload == ["item"] assert result.total == TOTAL_ITEMS - assert result.page == 2 - assert result.page_size == 5 - - -def test_paginated_result_total_pages_zero(): - result = PaginatedResult(payload=[], total=0, page=1, page_size=10) - - assert result.total_pages == 0 + assert result.offset == 25 + assert result.limit == 5 def test_pagination_from_query_defaults(): result = Pagination.from_query({}) - assert result.page == 1 - assert result.page_size == 20 + assert result.offset == 0 + assert result.limit == 100 -def test_pagination_validates_page_size(): +def test_pagination_validates_limit(): with pytest.raises(ValidationError) as error_info: - Pagination.from_query({"page_size": TOO_LARGE_PAGE_SIZE}) + Pagination.from_query({"limit": TOO_LARGE_LIMIT}) - assert error_info.value.errors[0].pointer == "#/page_size" + assert error_info.value.errors[0].pointer == "#/limit" -@pytest.mark.parametrize("query", [{"page": "abc"}, {"page": "0"}]) -def test_pagination_validates_page(query): +@pytest.mark.parametrize("query", [{"offset": "abc"}, {"offset": "-1"}]) +def test_pagination_validates_offset(query): with pytest.raises(ValidationError) as error_info: Pagination.from_query(query) - assert error_info.value.errors[0].pointer == "#/page" - - -def test_pagination_links_first_page(): - result = PaginationLinksBuilder.build( - "https://example.com/orders", PaginatedResult(payload=[], total=0, page=1, page_size=10) - ) - - assert result["prev"] is None - assert result["next"] is None - assert result["last"] == "https://example.com/orders?page=1&page_size=10" + assert error_info.value.errors[0].pointer == "#/offset" -def test_pagination_links_replace_page_params(): - result = PaginationLinksBuilder.build( - "https://example.com/orders?foo=bar&page=9&page_size=1", - PaginatedResult(payload=[], total=TOTAL_ITEMS, page=2, page_size=10), - ) +def test_pagination_accepts_count_only_limit(): + result = Pagination.from_query({"limit": "0"}) - assert result["self"] == "https://example.com/orders?foo=bar&page=2&page_size=10" - assert result["first"] == "https://example.com/orders?foo=bar&page=1&page_size=10" - assert result["prev"] == "https://example.com/orders?foo=bar&page=1&page_size=10" - assert result["next"] == "https://example.com/orders?foo=bar&page=3&page_size=10" - assert result["last"] == "https://example.com/orders?foo=bar&page=3&page_size=10" + assert result.limit == 0 diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index 9ee1f35..9dbad74 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -60,7 +60,7 @@ def test_api_response_ok_serializes_body(): assert result.status_code == HTTPStatus.OK assert json.loads(result.body) == { "data": {"id": "ORD-1"}, - "meta": {"total": 1, "page": None, "page_size": None, "total_pages": None}, + "$meta": {"total": 1, "offset": None, "limit": None}, "links": { "self": "https://example.com/orders/ORD-1", "first": None, @@ -90,17 +90,14 @@ def test_api_response_preserves_typed_metadata(): assert result.links is links -def test_api_response_paginated_adds_links(): +def test_api_response_paginated_adds_meta(): paginated_result = PaginatedResult( - payload=[{"id": "ORD-1"}], total=TOTAL_ITEMS, page=2, page_size=5 + payload=[{"id": "ORD-1"}], total=TOTAL_ITEMS, offset=5, limit=5 ) - result = APIResponse.paginated(paginated_result).to_http_response( - request_url="https://example.com/orders?page=2&page_size=5" - ) + result = APIResponse.paginated(paginated_result).to_http_response() payload = json.loads(result.body) assert result.status_code == HTTPStatus.OK assert payload["data"] == [{"id": "ORD-1"}] - assert payload["meta"] == {"total": 12, "page": 2, "page_size": 5, "total_pages": 3} - assert payload["links"]["next"] == "https://example.com/orders?page=3&page_size=5" + assert payload["$meta"] == {"pagination": {"offset": 5, "limit": 5, "total": 12}} diff --git a/tests/services/mpt_api_service/test_base.py b/tests/services/mpt_api_service/test_base.py index 8fea324..d282611 100644 --- a/tests/services/mpt_api_service/test_base.py +++ b/tests/services/mpt_api_service/test_base.py @@ -1,4 +1,5 @@ import asyncio +from dataclasses import dataclass from mpt_extension_sdk.models.base import BaseModel from mpt_extension_sdk.services.api_client_v2.mpt_api_client import AsyncMPTClient @@ -12,23 +13,37 @@ def from_payload(cls, payload): class FakeService(BaseService[FakeModel]): - async def get_all(self, collection, batch_size=100): - return await self._iterate_all(collection, FakeModel, batch_size=batch_size) + """Fake service exposing base pagination for tests.""" -class FakeCollection: - def __init__(self, elements): +@dataclass +class FakePagination: + limit: int + offset: int + total: int + + +@dataclass +class FakeMeta: + pagination: FakePagination + + +class FakePage: + def __init__(self, elements, meta=None): self.elements = elements - self.batch_sizes = [] + self.meta = meta + + def __iter__(self): + return iter(self.elements) - async def iterate(self, *, batch_size): - self.batch_sizes.append(batch_size) - for element in self.elements: - yield element + def __len__(self): + return len(self.elements) -def test_get_all_collects_models(mocker): - collection = FakeCollection(["one", "two"]) +def test_paginate_fetches_page(mocker): + meta = FakeMeta(FakePagination(limit=2, offset=4, total=10)) + collection = mocker.Mock(spec=["fetch_page"]) + collection.fetch_page = mocker.AsyncMock(return_value=FakePage(["one", "two"], meta=meta)) mocker.patch.object( FakeModel, "from_payload", @@ -37,16 +52,10 @@ def test_get_all_collects_models(mocker): ) service = FakeService(mocker.Mock(spec=AsyncMPTClient)) - result = asyncio.run(service.get_all(collection)) - - assert result == [{"payload": "one"}, {"payload": "two"}] - - -def test_get_all_passes_batch_size(mocker): - collection = FakeCollection(["x"]) - mocker.patch.object(FakeModel, "from_payload", autospec=True, return_value={"payload": "x"}) - service = FakeService(mocker.Mock()) - - asyncio.run(service.get_all(collection, batch_size=50)) # act + result = asyncio.run(service._paginate(collection, FakeModel, offset=4, limit=2)) - assert collection.batch_sizes == [50] + collection.fetch_page.assert_awaited_once_with(offset=4, limit=2) + assert result.offset == 4 + assert result.limit == 2 + assert result.resources == [{"payload": "one"}, {"payload": "two"}] + assert result.total == 10 diff --git a/tests/services/mpt_api_service/test_product.py b/tests/services/mpt_api_service/test_product.py index d5b73ca..e0826ca 100644 --- a/tests/services/mpt_api_service/test_product.py +++ b/tests/services/mpt_api_service/test_product.py @@ -22,25 +22,28 @@ def factory(): def test_get_items_returns_empty_without_ids(mocker, product_item_service_factory): service, items_client = product_item_service_factory() - iterate_all = mocker.patch.object(service, "_iterate_all", autospec=True) + paginate = mocker.patch.object(service, "_paginate", autospec=True) result = asyncio.run(service.get_product_one_time_items_by_ids("PROD-1", [])) assert result == [] items_client.filter.assert_not_called() - iterate_all.assert_not_called() + paginate.assert_not_called() def test_get_items_filters_and_collects(mocker, product_item_service_factory): service, items_client = product_item_service_factory() - filtered_collection = mocker.Mock(spec=["iterate"]) + filtered_collection = mocker.Mock(spec=["fetch_page"]) items_client.filter.return_value = filtered_collection - iterate_all = mocker.patch.object( - service, "_iterate_all", autospec=True, return_value=["item-1", "item-2"] + paginate = mocker.patch.object( + service, + "_paginate", + autospec=True, + return_value=mocker.Mock(resources=["item-1", "item-2"]), ) result = asyncio.run(service.get_product_one_time_items_by_ids("PROD-1", ["ITEM-1", "ITEM-2"])) assert result == ["item-1", "item-2"] items_client.filter.assert_called_once_with(mocker.ANY) - iterate_all.assert_awaited_once_with(filtered_collection, ProductItem) + paginate.assert_awaited_once_with(filtered_collection, ProductItem, limit=2)