From c8637440127d6d01edbc2cc89e9011b30d862f18 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:41:33 +0100 Subject: [PATCH] feat: PaginatedResponse[T] as discriminated union annotation (#142) --- docs/module/schemas.md | 4 +++- src/fastapi_toolsets/schemas.py | 22 +++++++++++++++++----- tests/test_schemas.py | 22 +++++++++++++++++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/module/schemas.md b/docs/module/schemas.md index 13a3fc2..ca8686e 100644 --- a/docs/module/schemas.md +++ b/docs/module/schemas.md @@ -102,7 +102,9 @@ async def list_events( #### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) -Base class and return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)) +Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)). + +When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate: ```python from fastapi_toolsets.crud import PaginationType diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py index bf0e88b..504d0b5 100644 --- a/src/fastapi_toolsets/schemas.py +++ b/src/fastapi_toolsets/schemas.py @@ -1,9 +1,9 @@ """Base Pydantic schemas for API responses.""" from enum import Enum -from typing import Any, ClassVar, Generic, Literal +from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from .types import DataT @@ -138,9 +138,11 @@ class PaginatedResponse(BaseResponse, Generic[DataT]): Base class and return type for endpoints that support both pagination strategies. Use :class:`OffsetPaginatedResponse` or - :class:`CursorPaginatedResponse` when the strategy is fixed; use - ``PaginatedResponse`` as the return annotation for unified endpoints that - dispatch via :meth:`~fastapi_toolsets.crud.factory.AsyncCrud.paginate`. + :class:`CursorPaginatedResponse` when the strategy is fixed. + + When used as ``PaginatedResponse[T]`` in a return annotation, subscripting + returns ``Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`` + so FastAPI emits a proper ``oneOf`` + discriminator in the OpenAPI schema. """ data: list[DataT] @@ -148,6 +150,16 @@ class PaginatedResponse(BaseResponse, Generic[DataT]): pagination_type: PaginationType | None = None filter_attributes: dict[str, list[Any]] | None = None + def __class_getitem__( # type: ignore[invalid-method-override] + cls, item: type[Any] | tuple[type[Any], ...] + ) -> type[Any]: + if cls is PaginatedResponse and not isinstance(item, TypeVar): + return Annotated[ # type: ignore[invalid-return-type] + Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # type: ignore[invalid-type-form] + Field(discriminator="pagination_type"), + ] + return super().__class_getitem__(item) + class OffsetPaginatedResponse(PaginatedResponse[DataT]): """Paginated response with typed offset-based pagination metadata. diff --git a/tests/test_schemas.py b/tests/test_schemas.py index a5f17a0..3433d52 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -304,7 +304,7 @@ class TestPaginatedResponse: page=1, has_more=False, ) - response = PaginatedResponse[dict]( + response = PaginatedResponse( data=[], pagination=pagination, ) @@ -313,6 +313,26 @@ class TestPaginatedResponse: assert response.data == [] assert response.pagination.total_count == 0 + def test_class_getitem_with_concrete_type_returns_discriminated_union(self): + """PaginatedResponse[T] with a concrete type returns a discriminated Annotated union.""" + import typing + + alias = PaginatedResponse[dict] + args = typing.get_args(alias) + # args[0] is the Union, args[1] is the FieldInfo discriminator + union_args = typing.get_args(args[0]) + assert CursorPaginatedResponse[dict] in union_args + assert OffsetPaginatedResponse[dict] in union_args + + def test_class_getitem_with_typevar_returns_generic(self): + """PaginatedResponse[TypeVar] falls through to Pydantic generic parametrisation.""" + from typing import TypeVar + + T = TypeVar("T") + alias = PaginatedResponse[T] + # Should be a generic alias, not an Annotated union + assert not hasattr(alias, "__metadata__") + def test_generic_type_hint(self): """PaginatedResponse supports generic type hints.""" pagination = OffsetPagination(