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
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
@@ -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,
+2 -2
View File
@@ -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
@@ -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": <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 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,
@@ -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:
@@ -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,
@@ -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:
@@ -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:
@@ -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,
+1 -1
View File
@@ -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)
+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)