Multiplayer scopone scientifico backend on the kaya framework:
- OIDC login (kaya-oidc), session-backed WebSocket auth
- Pure rules engine (forced captures, scopa, primiera scoring) with
full-match simulation tests
- Live game state in Redis (JSON + TTL, join codes, per-game locks,
pub/sub state push); in-memory fallback for tests
- WebSocket /ws/games/{id} for real-time play; REST lobby endpoints
(create/join/snapshot) with hidden-hand views
- Finished matches persisted to Postgres (Tortoise + aerich) for match
history and leaderboard endpoints
- Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app
- 45 tests passing; mypy clean
74 lines
2.3 KiB
Python
74 lines
2.3 KiB
Python
"""JSON helpers for kaya HTTP handlers.
|
|
|
|
Kaya has no built-in request/response JSON helpers: the request body is an
|
|
async byte stream on ``ctx.request_body`` and responses are sent with
|
|
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
|
|
parsing JSON, and sending JSON responses.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, List, Mapping
|
|
|
|
from kaya.core import HttpContext
|
|
|
|
JSON_HEADERS = {"content-type": ("application/json",)}
|
|
|
|
|
|
class JsonRequestError(ValueError):
|
|
"""Raised by :func:`read_json` when the request body is not valid JSON
|
|
or is not a JSON object."""
|
|
|
|
|
|
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
|
|
"""Parse a raw query string into a mapping of param name to list of
|
|
values.
|
|
|
|
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
|
|
incantation; always returns a mapping (never None).
|
|
"""
|
|
from urllib.parse import parse_qs
|
|
|
|
return parse_qs(query_string, keep_blank_values=True)
|
|
|
|
|
|
async def read_json(ctx: HttpContext) -> dict:
|
|
"""Drain and parse the request body as JSON.
|
|
|
|
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
|
|
with a short human-readable reason on failure.
|
|
"""
|
|
body = b""
|
|
async for chunk in ctx.request_body:
|
|
body += chunk
|
|
if not body:
|
|
raise JsonRequestError("empty body")
|
|
try:
|
|
parsed = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise JsonRequestError("invalid JSON") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise JsonRequestError("JSON body must be an object")
|
|
return parsed
|
|
|
|
|
|
async def read_json_optional(ctx: HttpContext) -> dict:
|
|
"""Like :func:`read_json` but treats an empty body as ``{}``."""
|
|
try:
|
|
return await read_json(ctx)
|
|
except JsonRequestError as exc:
|
|
if str(exc) == "empty body":
|
|
return {}
|
|
raise
|
|
|
|
|
|
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
|
|
"""Send ``payload`` as a JSON response."""
|
|
body = json.dumps(payload).encode("utf-8")
|
|
await ctx.send_bytes(status, body, JSON_HEADERS)
|
|
|
|
|
|
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
|
|
"""Send a JSON error envelope."""
|
|
await send_json(ctx, status, {"error": message})
|