Document every API response body in the OpenAPI spec
CI / Build and push docker image (push) Successful in 6m2s
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:
+3
-1
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user