refactor(openapi): typed app response models + MAX_PAGE_LIMIT

Adds AppListRow, AppInfoResponse, AppDescribeInfo, AppDescribeResponse
per spec docs/specs/v1.0/server/endpoints.md (every response a typed
Pydantic model). Adopts PEP 695 generic syntax for PaginationEnvelope
(drops legacy TypeVar + UP046 noqa). Centralizes the per-endpoint
limit cap as MAX_PAGE_LIMIT = 200.
This commit is contained in:
GareArc 2026-05-05 19:34:15 -07:00
parent 783dfe38a0
commit 069fdd4894
No known key found for this signature in database
2 changed files with 91 additions and 3 deletions

View File

@ -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]

View File

@ -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