From 1d88f8efbe469c4c4243ac68964b8f8df11f148e Mon Sep 17 00:00:00 2001 From: Jiang Date: Thu, 30 Jul 2026 21:50:21 +0800 Subject: [PATCH] fix(api): wrap pre-paginated list responses --- app/api/v1/rest_router.py | 62 ++++++++++++++++++++---------- tests/api/test_openapi_contract.py | 30 ++++++++++++++- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py index fc736ee..496a2d9 100644 --- a/app/api/v1/rest_router.py +++ b/app/api/v1/rest_router.py @@ -201,18 +201,39 @@ def _with_header_project_context(endpoint, route_name: str): def _with_pagination(endpoint): signature = inspect.signature(endpoint) - if "limit" in signature.parameters or "offset" in signature.parameters: - return endpoint + handler_limit_parameter = "limit" if "limit" in signature.parameters else None + handler_offset_parameter = next( + ( + parameter_name + for parameter_name in ("offset", "skip") + if parameter_name in signature.parameters + ), + None, + ) + handler_handles_pagination = bool( + handler_limit_parameter or handler_offset_parameter + ) @wraps(endpoint) async def wrapper(*args, **kwargs): - limit = kwargs.pop("_rest_limit") - offset = kwargs.pop("_rest_offset") + if handler_handles_pagination: + limit = kwargs.get(handler_limit_parameter, 0) + offset = kwargs.get(handler_offset_parameter, 0) + else: + limit = kwargs.pop("_rest_limit") + offset = kwargs.pop("_rest_offset") result = endpoint(*args, **kwargs) if inspect.isawaitable(result): result = await result if not isinstance(result, list): return result + if handler_handles_pagination: + return Page( + items=result, + total=offset + len(result), + limit=limit or len(result), + offset=offset, + ) return Page( items=result[offset : offset + limit], total=len(result), @@ -221,22 +242,23 @@ def _with_pagination(endpoint): ) parameters = list(signature.parameters.values()) - parameters.extend( - [ - inspect.Parameter( - "_rest_limit", - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=int, - default=Query(100, ge=1, le=1000, alias="limit"), - ), - inspect.Parameter( - "_rest_offset", - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=int, - default=Query(0, ge=0, alias="offset"), - ), - ] - ) + if not handler_handles_pagination: + parameters.extend( + [ + inspect.Parameter( + "_rest_limit", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(100, ge=1, le=1000, alias="limit"), + ), + inspect.Parameter( + "_rest_offset", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=Query(0, ge=0, alias="offset"), + ), + ] + ) wrapper.__signature__ = signature.replace(parameters=parameters) return wrapper diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index e590014..623e98a 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -4,7 +4,7 @@ from pathlib import Path from uuid import uuid4 import pytest -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI, Query from fastapi.routing import APIRoute from fastapi.testclient import TestClient @@ -254,3 +254,31 @@ def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None: assert response.json()["items"] == [ {"scheme_name": "burst_case", "scheme_type": "burst_analysis"} ] + + +def test_rest_runtime_wraps_handler_paginated_list() -> None: + source_router = APIRouter() + + @source_router.get("/records", response_model=list[int]) + async def list_records( + skip: int = Query(0, ge=0), + limit: int = Query(2, ge=1, le=10), + ) -> list[int]: + records = [10, 20, 30, 40] + return records[skip : skip + limit] + + app = FastAPI(redirect_slashes=False) + app.include_router(build_rest_router(source_router.routes), prefix="/api/v1") + + response = TestClient(app, raise_server_exceptions=False).get( + "/api/v1/records", + params={"skip": 1, "limit": 2}, + ) + + assert response.status_code == 200 + assert response.json() == { + "items": [20, 30], + "total": 3, + "limit": 2, + "offset": 1, + }