Skip to content
Open
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
30 changes: 23 additions & 7 deletions docs/sdk_usage/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ body, the SDK serializes the result using the standard JSON envelope:
```json
{
"data": {},
"meta": {},
"links": {}
"$meta": {}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Expand All @@ -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
Expand All @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions mock_app/api/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions mock_app/api/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
9 changes: 8 additions & 1 deletion mock_app/docs/example.http
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down
12 changes: 8 additions & 4 deletions mock_app/mocks/api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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:
Expand Down
16 changes: 11 additions & 5 deletions mock_app/sync/agreements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion mpt_extension_sdk/api/builders/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 20 additions & 76 deletions mpt_extension_sdk/api/pagination.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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"
)
]
)
Expand All @@ -78,69 +76,15 @@ 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:
"""Build a paginated result from request pagination input."""
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,
))
30 changes: 12 additions & 18 deletions mpt_extension_sdk/api/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -91,29 +90,24 @@ 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)

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)
Loading