Document every API response body in the OpenAPI spec
CI / Build and push docker image (push) Successful in 6m2s

kaya-openapi 0.0.8 supports named component schemas, so the routes'
description-only responses become $refs: schemas for the core payloads
(auth providers, lobby, game view, ongoing games, match history,
leaderboard, ratings) live in tavolo.platform.openapi and are registered
through the mixin's components. Engine-defined parts (options echo, game
views, match results) stay open objects on purpose. Error responses now
carry the error envelope schema, and a new suite sweeps the document to
keep every /api response typed.
This commit is contained in:
2026-10-14 22:07:38 +08:00
parent 1ab748a34f
commit e98d43e59a
12 changed files with 543 additions and 41 deletions
+3 -1
View File
@@ -61,7 +61,9 @@ alias that redirects to the first configured provider), type any username
session cookie. That cookie authenticates both the REST API and the session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint. WebSocket endpoint.
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`). OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`); every
endpoint documents its request body and its response bodies as named
component schemas.
## Configuration ## Configuration
@@ -46,6 +46,7 @@ from tavolo.briscola import (
) )
from tavolo.platform import GameRegistry, Platform, PlatformMixin from tavolo.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin from tavolo.platform.tortoise_mixin import TortoiseMixin
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
@@ -218,6 +219,7 @@ openapi_mixin = OpenAPIMixin(
description="Multiplayer card-game platform API", description="Multiplayer card-game platform API",
spec_path="/api/openapi.json", spec_path="/api/openapi.json",
docs_path="/api/docs", docs_path="/api/docs",
components={"schemas": SCHEMAS},
) )
tortoise_mixin = TortoiseMixin( tortoise_mixin = TortoiseMixin(
database_url=settings.database_url, database_url=settings.database_url,
+2 -2
View File
@@ -43,8 +43,8 @@ itself ships no game.
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`. history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`, - `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and `tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
page-number pagination, shared OpenAPI fragments, the TortoiseORM page-number pagination, shared OpenAPI fragments and response schemas,
lifecycle mixin. the TortoiseORM lifecycle mixin.
## Adding a game ## Adding a game
@@ -1,7 +1,23 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.""" """Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.
Two kinds of fragment live here:
* parameter lists (``PAGINATION_PARAMETERS``, ``PAGE_PARAMETERS``), used
directly in the route decorators, and
* reusable response schemas, registered as ``components.schemas`` by the
application (and the test fixtures) through ``SCHEMAS`` and referenced
from the routes with :func:`kaya.openapi.schema_ref`.
Engine-defined payload parts (lobby options echo, game views, match
results) are intentionally typed as open objects: the platform is
game-agnostic, so their exact shape belongs to each engine, not to this
document.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List, Optional, Sequence
from kaya.openapi import schema_ref
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{ {
@@ -38,3 +54,321 @@ PAGE_PARAMETERS: List[Dict[str, Any]] = [
"description": "Maximum number of results per page (clamped to [1, 100]).", "description": "Maximum number of results per page (clamped to [1, 100]).",
}, },
] ]
# ---------------------------------------------------------------------------
# Schema builders
# ---------------------------------------------------------------------------
def _object(
properties: Dict[str, Any],
required: Sequence[str] = (),
*,
description: Optional[str] = None,
additional_properties: bool = False,
) -> Dict[str, Any]:
"""An object schema with the given properties (JSON Schema 2020-12)."""
schema: Dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = list(required)
if description is not None:
schema["description"] = description
if additional_properties:
schema["additionalProperties"] = True
return schema
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
"""An open object: used for engine-specific payload parts."""
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
if description is not None:
schema["description"] = description
return schema
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
nullable = dict(schema)
kind = nullable.get("type")
if isinstance(kind, str):
nullable["type"] = [kind, "null"]
return nullable
def json_response(description: str, schema: Dict[str, Any]) -> Dict[str, Any]:
"""A JSON response object carrying ``schema``, for ``@operation``."""
return {
"description": description,
"content": {"application/json": {"schema": schema}},
}
def error_response(description: str) -> Dict[str, Any]:
"""The standard JSON error envelope (``{"error": <message>}``)."""
return json_response(description, schema_ref("Error"))
# ---------------------------------------------------------------------------
# Component schemas
# ---------------------------------------------------------------------------
ERROR_SCHEMA = _object({"error": {"type": "string"}}, ("error",))
HEALTH_SCHEMA = _object(
{"status": {"type": "string", "enum": ["ok"]}}, ("status",)
)
ME_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
},
("sub", "name"),
)
AUTH_PROVIDER_SCHEMA = _object(
{
"name": {"type": "string"},
"login_url": {"type": "string"},
},
("name", "login_url"),
)
AUTH_PROVIDERS_PAGE_SCHEMA = _object(
{"providers": _array(schema_ref("AuthProvider"))}, ("providers",)
)
GAME_TYPE_SCHEMA = _object(
{
"id": {"type": "string"},
"name": {"type": "string"},
"description": {"type": "string"},
"min_players": {"type": "integer"},
"max_players": {"type": "integer"},
"options_schema": _free_object(
"JSON-schema fragment describing the game-specific creation "
"options accepted by POST /api/games."
),
},
("id", "name", "description", "min_players", "max_players", "options_schema"),
)
GAME_TYPES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("GameType"))}, ("results",)
)
LOBBY_PLAYER_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({
"type": "string",
"description": "Game-defined team label; null for games without fixed teams.",
}),
},
("sub", "name", "seat", "team"),
)
_LOBBY_PROPERTIES: Dict[str, Any] = {
"id": {"type": "string"},
"join_code": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
}
_LOBBY_REQUIRED = ("id", "join_code", "game_type", "players", "seats_open")
LOBBY_SCHEMA = _object(
dict(_LOBBY_PROPERTIES),
_LOBBY_REQUIRED,
description="The lobby payload: the platform envelope plus the engine's "
"lobby_view fields (the creation-options echo and the phase).",
additional_properties=True,
)
GAME_PREVIEW_SCHEMA = _object(
{
**_LOBBY_PROPERTIES,
"game_name": {"type": "string"},
"game_description": {"type": "string"},
},
(*_LOBBY_REQUIRED, "game_name", "game_description"),
description="The join-code preview: the lobby payload plus the game's "
"display name and description.",
additional_properties=True,
)
GAME_VIEW_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"spectator": {"type": "boolean"},
"seats_open": {"type": "integer"},
"join_code": {
"type": "string",
"description": "The join code is a credential: present only in a "
"seated player's view, never to spectators.",
},
},
("id", "game_type", "spectator", "seats_open"),
description="A game snapshot: the platform envelope plus the engine's "
"view_for fields. Seated players get their personalized view "
"(own hand, join code); everyone else the read-only spectator "
"view (no hands, no join code).",
additional_properties=True,
)
ONGOING_GAME_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
"game_name": {"type": "string"},
"game_description": {"type": "string"},
"your_seat": _nullable({"type": "integer"}),
"created_at": _nullable({"type": "string", "format": "date-time"}),
},
(
"id",
"game_type",
"players",
"seats_open",
"game_name",
"game_description",
"your_seat",
"created_at",
),
description="One entry of the caller's ongoing-games list: the lobby "
"payload without the join code, plus the game name and the "
"caller's seat.",
additional_properties=True,
)
ONGOING_GAMES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("OngoingGame"))}, ("results",)
)
MATCH_PLAYER_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({"type": "string"}),
"won": {"type": "boolean"},
"score": {"type": "number"},
"elo_delta": _nullable({"type": "integer"}),
"details": _nullable(_free_object("Game-specific extras reported by the engine.")),
},
(
"user_sub",
"display_name",
"seat",
"team",
"won",
"score",
"elo_delta",
"details",
),
)
MATCH_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"result": _nullable(_free_object(
"The game-specific outcome reported by the engine."
)),
"started_at": {"type": "string", "format": "date-time"},
"finished_at": {"type": "string", "format": "date-time"},
"you_won": {"type": "boolean"},
"your_elo_delta": _nullable({"type": "integer"}),
"players": _array(schema_ref("MatchPlayer")),
},
(
"id",
"game_type",
"result",
"started_at",
"finished_at",
"you_won",
"your_elo_delta",
"players",
),
)
MATCHES_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("Match")),
"next_cursor": _nullable({"type": "string"}),
},
("results", "next_cursor"),
)
LEADERBOARD_ENTRY_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"elo": {"type": "integer"},
"matches": {"type": "integer"},
"wins": {"type": "integer"},
"points": {"type": "number"},
},
("user_sub", "display_name", "elo", "matches", "wins", "points"),
)
LEADERBOARD_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("LeaderboardEntry")),
"page": {"type": "integer"},
"pages": {"type": "integer"},
"total": {"type": "integer"},
"your_rank": _nullable({"type": "integer"}),
"your_page": _nullable({"type": "integer"}),
},
("results", "page", "pages", "total", "your_rank", "your_page"),
)
RATING_SCHEMA = _object(
{
"game_type": {"type": "string"},
"rating": {"type": "integer"},
"matches_played": {"type": "integer"},
},
("game_type", "rating", "matches_played"),
)
RATINGS_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("Rating"))}, ("results",)
)
#: Component name -> schema, passed to
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
SCHEMAS: Dict[str, Dict[str, Any]] = {
"Error": ERROR_SCHEMA,
"Health": HEALTH_SCHEMA,
"Me": ME_SCHEMA,
"AuthProvider": AUTH_PROVIDER_SCHEMA,
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
"GameType": GAME_TYPE_SCHEMA,
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
"Lobby": LOBBY_SCHEMA,
"GamePreview": GAME_PREVIEW_SCHEMA,
"GameView": GAME_VIEW_SCHEMA,
"OngoingGame": ONGOING_GAME_SCHEMA,
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
"MatchPlayer": MATCH_PLAYER_SCHEMA,
"Match": MATCH_SCHEMA,
"MatchesPage": MATCHES_PAGE_SCHEMA,
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
"Rating": RATING_SCHEMA,
"RatingsPage": RATINGS_PAGE_SCHEMA,
}
@@ -16,9 +16,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation, schema_ref
from ..http import send_json from ..http import send_json
from ..openapi import json_response
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -36,7 +37,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
description="Names and login URLs of the configured OIDC " description="Names and login URLs of the configured OIDC "
"providers, in configuration order.", "providers, in configuration order.",
tags=["auth"], tags=["auth"],
responses={200: {"description": "The configured providers"}}) responses={200: json_response("The configured providers",
schema_ref("AuthProvidersPage"))})
async def auth_providers(ctx: HttpContext) -> None: async def auth_providers(ctx: HttpContext) -> None:
await send_json( await send_json(
ctx, ctx,
@@ -19,7 +19,7 @@ from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, require_auth from ..auth import current_user, display_name, require_auth
from ..engine import GameSession, Seat from ..engine import GameSession, Seat
@@ -32,6 +32,7 @@ from ..http import (
send_error, send_error,
send_json, send_json,
) )
from ..openapi import error_response, json_response
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -144,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
"describes the per-game creation options accepted " "describes the per-game creation options accepted "
"by POST /api/games.", "by POST /api/games.",
tags=["games"], tags=["games"],
responses={200: {"description": "The available game types"}}) responses={200: json_response("The available game types",
schema_ref("GameTypesPage"))})
async def list_game_types(ctx: HttpContext) -> None: async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, { await send_json(ctx, 200, {
"results": [ "results": [
@@ -194,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
}, },
}, },
responses={ responses={
201: {"description": "The created lobby"}, 201: json_response("The created lobby", schema_ref("Lobby")),
400: {"description": "Invalid game_type, options or body"}, 400: error_response("Invalid game_type, options or body"),
401: {"description": "Authentication required"}, 401: error_response("Authentication required"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def create_game(ctx: HttpContext) -> None: async def create_game(ctx: HttpContext) -> None:
@@ -264,11 +266,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
}, },
}, },
responses={ responses={
200: {"description": "Seated; game state (may be playing)"}, 200: json_response(
400: {"description": "Missing code"}, "Seated: the lobby, or the personalized game state "
401: {"description": "Authentication required"}, "when the join started the match",
404: {"description": "Unknown join code"}, {"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
409: {"description": "Already joined or lobby full"}, ),
400: error_response("Missing code"),
401: error_response("Authentication required"),
404: error_response("Unknown join code"),
409: error_response("Already joined or lobby full"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def join_game(ctx: HttpContext) -> None: async def join_game(ctx: HttpContext) -> None:
@@ -328,10 +334,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
"its engine.", "its engine.",
tags=["games"], tags=["games"],
responses={ responses={
200: {"description": "The lobby preview"}, 200: json_response("The lobby preview",
400: {"description": "Missing code"}, schema_ref("GamePreview")),
401: {"description": "Authentication required"}, 400: error_response("Missing code"),
404: {"description": "Unknown join code"}, 401: error_response("Authentication required"),
404: error_response("Unknown join code"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def preview_game(ctx: HttpContext) -> None: async def preview_game(ctx: HttpContext) -> None:
@@ -356,9 +363,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
"join code, just what is public at the table.", "join code, just what is public at the table.",
tags=["games"], tags=["games"],
responses={ responses={
200: {"description": "The personalized (player) or " 200: json_response(
"read-only (spectator) game state"}, "The personalized (player) or read-only (spectator) game state",
404: {"description": "Game not found"}, schema_ref("GameView"),
),
404: error_response("Game not found"),
}) })
async def get_game(ctx: HttpContext, game_id: str) -> None: async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id) session = await platform.game_store.load(game_id)
@@ -378,8 +387,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
"list. Finished matches are not included.", "list. Finished matches are not included.",
tags=["games"], tags=["games"],
responses={ responses={
200: {"description": "The caller's ongoing games"}, 200: json_response("The caller's ongoing games",
401: {"description": "Authentication required"}, schema_ref("OngoingGamesPage")),
401: error_response("Authentication required"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def my_games(ctx: HttpContext) -> None: async def my_games(ctx: HttpContext) -> None:
@@ -4,7 +4,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation, schema_ref
from ..openapi import json_response
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/health") @app.GET("/api/health")
@operation(summary="Health check", @operation(summary="Health check",
tags=["health"], tags=["health"],
responses={200: {"description": "The service is up"}}) responses={200: json_response("The service is up", schema_ref("Health"))})
async def health(ctx: HttpContext) -> None: async def health(ctx: HttpContext) -> None:
await ctx.send_bytes( await ctx.send_bytes(
200, 200,
@@ -4,10 +4,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, require_auth from ..auth import current_user, display_name, require_auth
from ..http import send_json from ..http import send_json
from ..openapi import error_response, json_response
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -20,8 +21,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
"session; 401 when not logged in.", "session; 401 when not logged in.",
tags=["auth"], tags=["auth"],
responses={ responses={
200: {"description": "The current user"}, 200: json_response("The current user", schema_ref("Me")),
401: {"description": "Not logged in"}, 401: error_response("Not logged in"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None: async def me(ctx: HttpContext) -> None:
@@ -13,12 +13,17 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from uuid import UUID from uuid import UUID
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation, schema_ref
from ..auth import current_user, require_auth from ..auth import current_user, require_auth
from ..http import extract_query_params, send_error, send_json from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import PAGE_PARAMETERS, PAGINATION_PARAMETERS from ..openapi import (
PAGE_PARAMETERS,
PAGINATION_PARAMETERS,
error_response,
json_response,
)
from ..pagination import ( from ..pagination import (
CursorDecodeError, CursorDecodeError,
PageParamsError, PageParamsError,
@@ -131,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
tags=["stats"], tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER], parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={ responses={
200: {"description": "A page of matches"}, 200: json_response("A page of matches",
400: {"description": "Invalid pagination cursor or game_type"}, schema_ref("MatchesPage")),
401: {"description": "Authentication required"}, 400: error_response("Invalid pagination cursor or game_type"),
401: error_response("Authentication required"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def my_matches(ctx: HttpContext) -> None: async def my_matches(ctx: HttpContext) -> None:
@@ -186,8 +192,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
LEADERBOARD_FOCUS_PARAMETER, LEADERBOARD_FOCUS_PARAMETER,
], ],
responses={ responses={
200: {"description": "A page of the leaderboard"}, 200: json_response("A page of the leaderboard",
400: {"description": "Unknown game_type or invalid parameters"}, schema_ref("LeaderboardPage")),
400: error_response("Unknown game_type or invalid parameters"),
}) })
async def leaderboard(ctx: HttpContext) -> None: async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string) game_type, error = _parse_game_type(platform, ctx.query_string)
@@ -261,8 +268,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
"type they have played.", "type they have played.",
tags=["stats"], tags=["stats"],
responses={ responses={
200: {"description": "The caller's ratings"}, 200: json_response("The caller's ratings",
401: {"description": "Authentication required"}, schema_ref("RatingsPage")),
401: error_response("Authentication required"),
}) })
@require_auth(platform.oidc) @require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None: async def my_ratings(ctx: HttpContext) -> None:
@@ -45,6 +45,7 @@ from tavolo.platform import (
) )
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching) from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.deadlines import DeadlineScheduler from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.registry import GameRegistry from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore from tavolo.platform.store import InMemoryGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin from tavolo.platform.tortoise_mixin import TortoiseMixin
@@ -253,6 +254,7 @@ def make_platform(
description="test app", description="test app",
spec_path="/api/openapi.json", spec_path="/api/openapi.json",
docs_path="/api/docs", docs_path="/api/docs",
components={"schemas": SCHEMAS},
) )
scheduler = DeadlineScheduler( scheduler = DeadlineScheduler(
game_store, game_store,
+1 -1
View File
@@ -79,7 +79,7 @@ kaya-cors==0.0.7
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-oidc==0.0.7 kaya-oidc==0.0.7
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-openapi==0.0.7 kaya-openapi==0.0.8
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-otel==0.0.7 kaya-otel==0.0.7
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
+139
View File
@@ -0,0 +1,139 @@
"""OpenAPI document tests: components, response schemas and Swagger UI."""
from __future__ import annotations
import unittest
from typing import Any, Dict, Tuple
from httpx import ASGITransport, AsyncClient
from kaya.openapi import schema_ref
from tavolo.app import app
from tavolo.platform.openapi import SCHEMAS
from tests.helpers import async_test
#: Methods documented for the application's own routes.
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
class OpenApiDocumentTest(unittest.TestCase):
async def _get(self, path: str) -> Any:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get(path)
self.assertEqual(200, response.status_code)
return response
async def _spec(self) -> Dict[str, Any]:
response = await self._get("/api/openapi.json")
body: Dict[str, Any] = response.json()
return body
async def _schema(
self, spec: Dict[str, Any], path: str, method: str, status: str
) -> Dict[str, Any]:
schema: Dict[str, Any] = spec["paths"][path][method]["responses"][status][
"content"
]["application/json"]["schema"]
return schema
@async_test
async def test_components_registered(self) -> None:
spec = await self._spec()
self.assertEqual(SCHEMAS, spec["components"]["schemas"])
@async_test
async def test_every_api_response_documents_a_schema(self) -> None:
spec = await self._spec()
checked = 0
for path, item in spec["paths"].items():
if not path.startswith("/api/"):
continue
for method, operation in item.items():
if method not in HTTP_METHODS:
continue
for status, response in operation["responses"].items():
with self.subTest(path=path, method=method, status=status):
if status == "204":
# No content, so no schema either.
self.assertNotIn("content", response)
continue
self.assertIn("application/json", response["content"])
self.assertTrue(
response["content"]["application/json"]["schema"]
)
checked += 1
self.assertGreater(checked, 0)
@async_test
async def test_response_schemas_reference_components(self) -> None:
spec = await self._spec()
cases: Tuple[Tuple[str, str, str, Dict[str, Any]], ...] = (
("/api/health", "get", "200", schema_ref("Health")),
("/api/me", "get", "200", schema_ref("Me")),
("/api/me", "get", "401", schema_ref("Error")),
("/api/auth/providers", "get", "200", schema_ref("AuthProvidersPage")),
("/api/game-types", "get", "200", schema_ref("GameTypesPage")),
("/api/games", "post", "201", schema_ref("Lobby")),
("/api/games/preview", "get", "200", schema_ref("GamePreview")),
("/api/games/{game_id}", "get", "200", schema_ref("GameView")),
("/api/games/{game_id}", "get", "404", schema_ref("Error")),
("/api/me/games", "get", "200", schema_ref("OngoingGamesPage")),
("/api/me/matches", "get", "200", schema_ref("MatchesPage")),
("/api/leaderboard", "get", "200", schema_ref("LeaderboardPage")),
("/api/me/ratings", "get", "200", schema_ref("RatingsPage")),
)
for path, method, status, expected in cases:
with self.subTest(path=path, method=method, status=status):
self.assertEqual(
expected, await self._schema(spec, path, method, status)
)
@async_test
async def test_join_response_is_lobby_or_game_view(self) -> None:
spec = await self._spec()
schema = await self._schema(spec, "/api/games/join", "post", "200")
self.assertEqual(
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, schema
)
@async_test
async def test_component_nesting_and_nullability(self) -> None:
spec = await self._spec()
schemas = spec["components"]["schemas"]
matches = schemas["MatchesPage"]["properties"]
self.assertEqual(schema_ref("Match"), matches["results"]["items"])
self.assertEqual(["string", "null"], matches["next_cursor"]["type"])
match = schemas["Match"]["properties"]
self.assertEqual(schema_ref("MatchPlayer"), match["players"]["items"])
self.assertEqual(["integer", "null"], match["your_elo_delta"]["type"])
lobby = schemas["Lobby"]
self.assertEqual(schema_ref("LobbyPlayer"), lobby["properties"]["players"]["items"])
self.assertIs(True, lobby["additionalProperties"])
self.assertEqual(
["string", "null"],
schemas["LobbyPlayer"]["properties"]["team"]["type"],
)
game_view = schemas["GameView"]
self.assertEqual("boolean", game_view["properties"]["spectator"]["type"])
self.assertIs(True, game_view["additionalProperties"])
# The join code is optional (players only), so it is not required.
self.assertNotIn("join_code", game_view["required"])
board = schemas["LeaderboardPage"]
self.assertEqual(["integer", "null"], board["properties"]["your_rank"]["type"])
self.assertEqual(
["integer", "null"], board["properties"]["your_page"]["type"]
)
self.assertEqual(schema_ref("LeaderboardEntry"),
board["properties"]["results"]["items"])
@async_test
async def test_docs_serve_swagger_ui(self) -> None:
response = await self._get("/api/docs")
self.assertIn("text/html", response.headers["content-type"])
self.assertIn("swagger-ui", response.text)