diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 18e0b876b9..ee361531e9 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -2,11 +2,14 @@ from __future__ import annotations -from typing import Any, Generic, TypeVar +from typing import Any from pydantic import BaseModel -T = TypeVar("T") +# Server-side cap on `limit` query param for any /openapi/v1/* list endpoint. +# Sibling endpoints (`/apps`, `/account/sessions`, future routes) all clamp to +# this; do not introduce per-endpoint caps without raising the constant. +MAX_PAGE_LIMIT = 200 class UsageInfo(BaseModel): @@ -20,7 +23,7 @@ class MessageMetadata(BaseModel): retriever_resources: list[dict[str, Any]] = [] -class PaginationEnvelope(BaseModel, Generic[T]): # noqa: UP046 +class PaginationEnvelope[T](BaseModel): """Canonical pagination envelope for `/openapi/v1/*` list endpoints.""" page: int @@ -32,3 +35,32 @@ class PaginationEnvelope(BaseModel, Generic[T]): # noqa: UP046 @classmethod def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> PaginationEnvelope[T]: return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) + + +class AppListRow(BaseModel): + id: str + name: str + description: str | None = None + mode: str + tags: list[dict[str, str]] = [] + updated_at: str | None = None + created_by_name: str | None = None + + +class AppInfoResponse(BaseModel): + id: str + name: str + description: str | None = None + mode: str + author: str | None = None + tags: list[dict[str, str]] = [] + + +class AppDescribeInfo(AppInfoResponse): + updated_at: str | None = None + service_api_enabled: bool + + +class AppDescribeResponse(BaseModel): + info: AppDescribeInfo + parameters: dict[str, Any] diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py index ce32f80a2e..9fb4120d14 100644 --- a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py +++ b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py @@ -43,3 +43,59 @@ def test_envelope_has_more_false_when_total_within_page_window(): def test_envelope_has_more_false_for_last_page(): env = PaginationEnvelope[_Row].build(page=3, limit=20, total=42, items=[_Row(id="a", name="A")]) assert env.has_more is False + + +def test_max_page_limit_is_200(): + from controllers.openapi._models import MAX_PAGE_LIMIT + + assert MAX_PAGE_LIMIT == 200 + + +def test_envelope_uses_pep695_generics(): + """Verify the class accepts type parameter via PEP 695 syntax — + i.e., model_fields surfaces the generic-parameterized data list.""" + from controllers.openapi._models import PaginationEnvelope + + Parameterized = PaginationEnvelope[dict] + fields = PaginationEnvelope.model_fields + assert {"page", "limit", "total", "has_more", "data"} <= set(fields) + + +def test_app_info_response_dump_matches_spec(): + from controllers.openapi._models import AppInfoResponse + + obj = AppInfoResponse( + id="app1", + name="X", + description="d", + mode="chat", + author="alice", + tags=[{"name": "prod"}], + ) + assert obj.model_dump(mode="json") == { + "id": "app1", + "name": "X", + "description": "d", + "mode": "chat", + "author": "alice", + "tags": [{"name": "prod"}], + } + + +def test_app_describe_response_nests_info_and_parameters(): + from controllers.openapi._models import AppDescribeInfo, AppDescribeResponse + + info = AppDescribeInfo( + id="app1", + name="X", + mode="chat", + description=None, + tags=[], + author=None, + updated_at="2026-05-05T00:00:00+00:00", + service_api_enabled=True, + ) + obj = AppDescribeResponse(info=info, parameters={"opening_statement": None}) + dumped = obj.model_dump(mode="json") + assert dumped["info"]["service_api_enabled"] is True + assert dumped["parameters"]["opening_statement"] is None