diff --git a/server/README.md b/server/README.md index 92e8922..5d57b30 100644 --- a/server/README.md +++ b/server/README.md @@ -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 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 diff --git a/server/packages/tavolo-app/src/tavolo/app.py b/server/packages/tavolo-app/src/tavolo/app.py index 15583b6..76dfe16 100644 --- a/server/packages/tavolo-app/src/tavolo/app.py +++ b/server/packages/tavolo-app/src/tavolo/app.py @@ -46,6 +46,7 @@ from tavolo.briscola import ( ) from tavolo.platform import GameRegistry, Platform, PlatformMixin from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin +from tavolo.platform.openapi import SCHEMAS from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore from tavolo.platform.tortoise_mixin import TortoiseMixin from tavolo.scopa import ScopaEngine, ScopaQuattroEngine @@ -218,6 +219,7 @@ openapi_mixin = OpenAPIMixin( description="Multiplayer card-game platform API", spec_path="/api/openapi.json", docs_path="/api/docs", + components={"schemas": SCHEMAS}, ) tortoise_mixin = TortoiseMixin( database_url=settings.database_url, diff --git a/server/packages/tavolo-platform/README.md b/server/packages/tavolo-platform/README.md index ffa314a..394e842 100644 --- a/server/packages/tavolo-platform/README.md +++ b/server/packages/tavolo-platform/README.md @@ -43,8 +43,8 @@ itself ships no game. history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`. - `auth.py`, `http.py`, `pagination.py`, `openapi.py`, `tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and - page-number pagination, shared OpenAPI fragments, the TortoiseORM - lifecycle mixin. + page-number pagination, shared OpenAPI fragments and response schemas, + the TortoiseORM lifecycle mixin. ## Adding a game diff --git a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py index 4125530..a789621 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py @@ -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 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]] = [ { @@ -38,3 +54,321 @@ PAGE_PARAMETERS: List[Dict[str, Any]] = [ "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": }``).""" + 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, +} diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py index f0d9cfd..85eb193 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py @@ -16,9 +16,10 @@ from __future__ import annotations from typing import TYPE_CHECKING from kaya.core import HttpContext, KayaApp -from kaya.openapi import operation +from kaya.openapi import operation, schema_ref from ..http import send_json +from ..openapi import json_response if TYPE_CHECKING: from ..mixin import Platform @@ -36,7 +37,8 @@ def register(app: KayaApp, platform: "Platform") -> None: description="Names and login URLs of the configured OIDC " "providers, in configuration order.", 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: await send_json( ctx, diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py index 8938813..d067af8 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -19,7 +19,7 @@ from logging import getLogger from typing import TYPE_CHECKING, Any, Dict, Optional 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 ..engine import GameSession, Seat @@ -32,6 +32,7 @@ from ..http import ( send_error, send_json, ) +from ..openapi import error_response, json_response if TYPE_CHECKING: from ..mixin import Platform @@ -144,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None: "describes the per-game creation options accepted " "by POST /api/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: await send_json(ctx, 200, { "results": [ @@ -194,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None: }, }, responses={ - 201: {"description": "The created lobby"}, - 400: {"description": "Invalid game_type, options or body"}, - 401: {"description": "Authentication required"}, + 201: json_response("The created lobby", schema_ref("Lobby")), + 400: error_response("Invalid game_type, options or body"), + 401: error_response("Authentication required"), }) @require_auth(platform.oidc) async def create_game(ctx: HttpContext) -> None: @@ -264,11 +266,15 @@ def register(app: KayaApp, platform: "Platform") -> None: }, }, responses={ - 200: {"description": "Seated; game state (may be playing)"}, - 400: {"description": "Missing code"}, - 401: {"description": "Authentication required"}, - 404: {"description": "Unknown join code"}, - 409: {"description": "Already joined or lobby full"}, + 200: json_response( + "Seated: the lobby, or the personalized game state " + "when the join started the match", + {"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, + ), + 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) async def join_game(ctx: HttpContext) -> None: @@ -328,10 +334,11 @@ def register(app: KayaApp, platform: "Platform") -> None: "its engine.", tags=["games"], responses={ - 200: {"description": "The lobby preview"}, - 400: {"description": "Missing code"}, - 401: {"description": "Authentication required"}, - 404: {"description": "Unknown join code"}, + 200: json_response("The lobby preview", + schema_ref("GamePreview")), + 400: error_response("Missing code"), + 401: error_response("Authentication required"), + 404: error_response("Unknown join code"), }) @require_auth(platform.oidc) 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.", tags=["games"], responses={ - 200: {"description": "The personalized (player) or " - "read-only (spectator) game state"}, - 404: {"description": "Game not found"}, + 200: json_response( + "The personalized (player) or read-only (spectator) game state", + schema_ref("GameView"), + ), + 404: error_response("Game not found"), }) async def get_game(ctx: HttpContext, game_id: str) -> None: 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.", tags=["games"], responses={ - 200: {"description": "The caller's ongoing games"}, - 401: {"description": "Authentication required"}, + 200: json_response("The caller's ongoing games", + schema_ref("OngoingGamesPage")), + 401: error_response("Authentication required"), }) @require_auth(platform.oidc) async def my_games(ctx: HttpContext) -> None: diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py index 15261ad..82d3723 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py @@ -4,7 +4,9 @@ from __future__ import annotations from typing import TYPE_CHECKING 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: from ..mixin import Platform @@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None: @app.GET("/api/health") @operation(summary="Health check", 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: await ctx.send_bytes( 200, diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py index d53e04a..285fa8a 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py @@ -4,10 +4,11 @@ from __future__ import annotations from typing import TYPE_CHECKING 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 ..http import send_json +from ..openapi import error_response, json_response if TYPE_CHECKING: from ..mixin import Platform @@ -20,8 +21,8 @@ def register(app: KayaApp, platform: "Platform") -> None: "session; 401 when not logged in.", tags=["auth"], responses={ - 200: {"description": "The current user"}, - 401: {"description": "Not logged in"}, + 200: json_response("The current user", schema_ref("Me")), + 401: error_response("Not logged in"), }) @require_auth(platform.oidc) async def me(ctx: HttpContext) -> None: diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py index 35d65bd..11e94a5 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py @@ -13,12 +13,17 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from uuid import UUID 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 ..http import extract_query_params, send_error, send_json 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 ( CursorDecodeError, PageParamsError, @@ -131,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None: tags=["stats"], parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER], responses={ - 200: {"description": "A page of matches"}, - 400: {"description": "Invalid pagination cursor or game_type"}, - 401: {"description": "Authentication required"}, + 200: json_response("A page of matches", + schema_ref("MatchesPage")), + 400: error_response("Invalid pagination cursor or game_type"), + 401: error_response("Authentication required"), }) @require_auth(platform.oidc) async def my_matches(ctx: HttpContext) -> None: @@ -186,8 +192,9 @@ def register(app: KayaApp, platform: "Platform") -> None: LEADERBOARD_FOCUS_PARAMETER, ], responses={ - 200: {"description": "A page of the leaderboard"}, - 400: {"description": "Unknown game_type or invalid parameters"}, + 200: json_response("A page of the leaderboard", + schema_ref("LeaderboardPage")), + 400: error_response("Unknown game_type or invalid parameters"), }) async def leaderboard(ctx: HttpContext) -> None: 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.", tags=["stats"], responses={ - 200: {"description": "The caller's ratings"}, - 401: {"description": "Authentication required"}, + 200: json_response("The caller's ratings", + schema_ref("RatingsPage")), + 401: error_response("Authentication required"), }) @require_auth(platform.oidc) async def my_ratings(ctx: HttpContext) -> None: diff --git a/server/packages/tavolo-platform/tests/helpers.py b/server/packages/tavolo-platform/tests/helpers.py index b7f0b21..6402e66 100644 --- a/server/packages/tavolo-platform/tests/helpers.py +++ b/server/packages/tavolo-platform/tests/helpers.py @@ -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.deadlines import DeadlineScheduler +from tavolo.platform.openapi import SCHEMAS from tavolo.platform.registry import GameRegistry from tavolo.platform.store import InMemoryGameStore from tavolo.platform.tortoise_mixin import TortoiseMixin @@ -253,6 +254,7 @@ def make_platform( description="test app", spec_path="/api/openapi.json", docs_path="/api/docs", + components={"schemas": SCHEMAS}, ) scheduler = DeadlineScheduler( game_store, diff --git a/server/requirements.txt b/server/requirements.txt index 55e6c09..4c4b57b 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -79,7 +79,7 @@ kaya-cors==0.0.7 # via tavolo-app (pyproject.toml) kaya-oidc==0.0.7 # via tavolo-app (pyproject.toml) -kaya-openapi==0.0.7 +kaya-openapi==0.0.8 # via tavolo-app (pyproject.toml) kaya-otel==0.0.7 # via tavolo-app (pyproject.toml) diff --git a/server/tests/test_openapi.py b/server/tests/test_openapi.py new file mode 100644 index 0000000..35475fc --- /dev/null +++ b/server/tests/test_openapi.py @@ -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)