Files
tavolo/src/scopa/http.py
T
woggioni e9ddb82e9a Initial scopone scientifico backend
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
2026-09-16 13:20:05 +08:00

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})