Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s

Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
woggioni committed 2026-10-02 13:54:55 +00:00
1 parent adc3d340b1
commit 7b3b38ccbc
136 files changed
+16821 -11777

No files matched your search

@@ -0,0 +1,175 @@
//! Authentication routes for the single-page app.
//!
//! Each configured provider gets `/auth/<provider>/login`, `.../callback`
//! and `.../logout` routes (registered per provider, so an unknown provider
//! path falls through to the SPA fallback like in the Python server). This
//! module adds the provider-agnostic pieces:
//!
//! * `GET /api/auth/providers` lists the configured providers with their
//! login URLs, so the SPA can offer a choice without hardcoding names;
//! * `GET /auth/login` redirects to the first configured provider;
//! * `GET /auth/logout` redirects to the provider this session is logged in
//! with (or the SPA root when not logged in).
use axum::extract::{Extension, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::auth::logged_in_providers;
use crate::http::{extract_query_params, redirect, send_json, send_text};
use crate::oidc::OidcFlowError;
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes(providers: &[String]) -> Router<SharedPlatform> {
let mut router = Router::new()
.route("/api/auth/providers", get(providers_handler))
.route("/auth/login", get(generic_login))
.route("/auth/logout", get(generic_logout));
for provider in providers {
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/login"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_login(state, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/callback"),
get(
move |state: State<SharedPlatform>,
query: RawQuery,
session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_callback(state, query, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/logout"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_logout(state, session, name).await }
},
),
);
}
router
}
fn flow_error_response(error: OidcFlowError) -> Response {
match error {
OidcFlowError::BadRequest(message) => send_text(
StatusCode::BAD_REQUEST,
message,
"text/plain; charset=utf-8",
),
OidcFlowError::Internal(message) => send_text(
StatusCode::INTERNAL_SERVER_ERROR,
message,
"text/plain; charset=utf-8",
),
}
}
/// Names and login URLs of the configured providers, in configuration order.
async fn providers_handler(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<serde_json::Value> = platform
.oidc
.providers()
.map(|provider| {
json!({
"name": provider.name(),
"login_url": platform.oidc.login_url(provider.name()),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "providers": results }))
}
/// Redirect to the first configured provider's login.
async fn generic_login(State(platform): State<SharedPlatform>) -> Response {
redirect(platform.oidc.login_url(platform.oidc.first().name()))
}
/// Redirect to the logged-in provider's logout (or the SPA root).
async fn generic_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let logged_in = {
let session = session.lock().await;
logged_in_providers(&session)
};
match logged_in.first() {
Some(provider) if platform.oidc.get(provider).is_some() => {
redirect(format!("/auth/{provider}/logout"))
}
_ => redirect("/"),
}
}
async fn provider_login(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.begin_login(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_callback(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
let params = extract_query_params(query.as_deref());
match provider.handle_callback(&session, &params).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.logout(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
@@ -0,0 +1,421 @@
//! Game lobby endpoints.
//!
//! A game starts as a lobby: the creator is seated first and shares the
//! six-character `join_code`. When the lobby fills up, the engine starts the
//! match. Live play then happens over the `/ws/games/{id}` websocket; these
//! endpoints cover creation, joining and snapshotting state.
//!
//! Everything game-specific — which options a game accepts, when the lobby
//! is full, what the personalized view looks like — is delegated to the
//! engine registered for the session's `game_type`.
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::{get, post};
use axum::Extension;
use axum::Router;
use rand::seq::SliceRandom;
use serde_json::{json, Value};
use crate::auth::{current_user, display_name};
use crate::datetime::now;
use crate::engine::GameSession;
use crate::http::{
extract_query_params, py_repr, read_json, read_json_optional, send_error, send_json,
unauthorized,
};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// Join codes avoid characters that are easy to confuse when read aloud.
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH: usize = 6;
const MAX_CODE_ATTEMPTS: usize = 20;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/game-types", get(list_game_types))
.route("/api/games", post(create_game))
.route("/api/games/join", post(join_game))
.route("/api/games/preview", get(preview_game))
.route("/api/games/{game_id}", get(get_game))
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| *CODE_ALPHABET.choose(&mut rng).expect("non-empty alphabet") as char)
.collect()
}
async fn unique_code(platform: &Platform) -> Result<String, Response> {
for _ in 0..MAX_CODE_ATTEMPTS {
let code = random_code();
match platform.game_store.find_by_code(&code).await {
Ok(None) => return Ok(code),
Ok(Some(_)) => continue,
Err(err) => {
tracing::error!("join code lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a join code",
));
}
}
}
Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a unique join code",
))
}
fn merge(base: &mut Value, extra: Value) {
if let (Value::Object(base), Value::Object(extra)) = (base, extra) {
for (key, value) in extra {
base.insert(key, value);
}
}
}
fn lobby_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let players: Vec<Value> = game
.players
.iter()
.enumerate()
.map(|(index, seat)| {
json!({
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
})
})
.collect();
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
"players": players,
"seats_open": engine.max_players().saturating_sub(game.players.len()),
});
merge(&mut payload, engine.lobby_view(game));
Ok(payload)
}
fn view_payload(platform: &Platform, game: &GameSession, sub: &str) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
merge(&mut payload, engine.view_for(game, sub));
Ok(payload)
}
fn preview_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = lobby_payload(platform, game)?;
merge(
&mut payload,
json!({
"game_name": engine.name(),
"game_description": engine.description(),
}),
);
Ok(payload)
}
/// Every game the platform can host, for the match-creation dropdown.
async fn list_game_types(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<Value> = platform
.registry
.all()
.iter()
.map(|engine| {
json!({
"id": engine.id(),
"name": engine.name(),
"description": engine.description(),
"min_players": engine.min_players(),
"max_players": engine.max_players(),
"options_schema": engine.options_schema(),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
/// Create a lobby game and seat the caller in seat 0.
async fn create_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json_optional(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let engine = match body.get("game_type") {
None | Some(Value::Null) => match platform.registry.default_engine() {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"no game engines registered",
)
}
},
Some(Value::String(name)) => match platform.registry.require(name) {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(name.clone()))
),
)
}
},
Some(other) => {
return send_error(
StatusCode::BAD_REQUEST,
format!("unknown game_type: {}", py_repr(other)),
)
}
};
let options = body.get("options").cloned().unwrap_or_else(|| json!({}));
let Value::Object(options) = options else {
return send_error(StatusCode::BAD_REQUEST, "options must be an object");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let join_code = match unique_code(&platform).await {
Ok(code) => code,
Err(response) => return response,
};
let mut game = GameSession::new(uuid::Uuid::new_v4().to_string(), engine.id(), join_code);
game.creator_sub = user.sub.clone();
game.players = vec![crate::engine::Seat::new(&user.sub, display_name(&user))];
game.created_at = Some(now());
if let Err(err) = engine.create(&mut game, &options) {
match err.as_game() {
Some(game_error) => {
return send_error(StatusCode::BAD_REQUEST, game_error.message.clone())
}
None => {
tracing::error!("game create failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("game save failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
tracing::info!(
"game {} created by {} ({}, options {})",
game.id,
user.sub,
engine.id(),
serde_json::to_string(&options).unwrap_or_default()
);
match lobby_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::CREATED, &payload),
Err(response) => response,
}
}
/// Join with `{"code": "ABC123"}`; the last player triggers the start.
async fn join_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let Some(code) = body
.get("code")
.and_then(Value::as_str)
.filter(|code| !code.is_empty())
else {
return send_error(StatusCode::BAD_REQUEST, "code is required");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let existing = match platform.game_store.find_by_code(code).await {
Ok(Some(existing)) => existing,
Ok(None) => {
tracing::debug!("join rejected for {}: unknown code {code:?}", user.sub);
return send_error(StatusCode::NOT_FOUND, "unknown join code");
}
Err(err) => {
tracing::error!("join lookup failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let game_id = existing.id.clone();
let lock = match platform.game_store.acquire_lock(&game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("join lock failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(&game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("join load failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}) else {
return Err(send_error(StatusCode::NOT_FOUND, "unknown join code"));
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("join engine lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
};
if let Err(err) = engine.join(&mut game, &user.sub, &display_name(&user)) {
match err.as_game() {
Some(game_error) => {
tracing::debug!(
"join rejected for {} in game {game_id}: {}",
user.sub,
game_error.message
);
return Err(send_error(StatusCode::CONFLICT, game_error.message.clone()));
}
None => {
tracing::error!("join failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("join save failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
let _ = platform.game_store.publish(&game.id).await;
if let Err(err) = platform.scheduler.sync_deadline(&game).await {
tracing::error!("join sync_deadline failed: {err}");
}
let seat = game.seat_index_of(&user.sub).unwrap_or(0);
if engine.in_lobby(&game) {
tracing::info!(
"{} joined game {} (seat {}, {}/{} players)",
user.sub,
game.id,
seat,
game.players.len(),
engine.max_players()
);
return lobby_payload(&platform, &game);
}
tracing::info!(
"{} joined game {} (seat {}); match started",
user.sub,
game.id,
seat
);
view_payload(&platform, &game, &user.sub)
}
.await;
let _ = lock.release().await;
match result {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
/// Pre-join summary of the lobby behind a code.
async fn preview_game(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let code = params
.get("code")
.and_then(|values| values.first())
.map(|code| code.trim().to_string())
.unwrap_or_default();
if code.is_empty() {
return send_error(StatusCode::BAD_REQUEST, "code is required");
}
match platform.game_store.find_by_code(&code).await {
Ok(Some(game)) => match preview_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
},
Ok(None) => send_error(StatusCode::NOT_FOUND, "unknown join code"),
Err(err) => {
tracing::error!("preview lookup failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not preview game")
}
}
}
/// Personalized snapshot; only seated players may read a game.
async fn get_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
) -> Response {
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => return send_error(StatusCode::NOT_FOUND, "game not found"),
Err(err) => {
tracing::error!("game load failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load game");
}
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
if !game.seated(&user.sub) {
return send_error(StatusCode::FORBIDDEN, "forbidden");
}
match view_payload(&platform, &game, &user.sub) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
@@ -0,0 +1,20 @@
//! Liveness probe.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::http::send_json;
use crate::platform::SharedPlatform;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/health", get(health))
}
/// Health check: the service is up.
async fn health(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &json!({ "status": "ok" }))
}
@@ -0,0 +1,32 @@
//! Whoami endpoint: lets the single-page app detect the login state.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::json;
use crate::auth::{current_user, display_name};
use crate::http::{send_json, unauthorized};
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/me", get(me))
}
/// Current user; 401 when not logged in.
async fn me(
State(_platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
send_json(
StatusCode::OK,
&json!({ "sub": user.sub, "name": display_name(&user) }),
)
}
@@ -0,0 +1,33 @@
//! The platform's HTTP surface: lobby, stats, auth and health routes.
//!
//! Handlers live in submodules mirroring the Python implementation; this
//! module assembles them into one axum [`Router`].
pub mod auth;
pub mod games;
pub mod health;
pub mod me;
pub mod openapi;
pub mod stats;
use axum::routing::get;
use axum::Router;
use crate::platform::SharedPlatform;
use crate::ws;
/// All platform routes (HTTP + the live-play websocket).
///
/// `providers` lists the configured OIDC provider names; login/callback/
/// logout routes are registered per provider so unknown provider paths fall
/// through to the router's fallback.
pub fn router(providers: &[String]) -> Router<SharedPlatform> {
Router::new()
.merge(health::routes())
.merge(me::routes())
.merge(auth::routes(providers))
.merge(games::routes())
.merge(stats::routes())
.merge(openapi::routes())
.route("/ws/games/{game_id}", get(ws::game_socket))
}
@@ -0,0 +1,314 @@
//! OpenAPI document and Swagger UI, mirroring kaya-openapi's output for the
//! tavolo API.
//!
//! The document is assembled from the route metadata below (kaya generated it
//! from the routing tree on every request). Wildcard SPA routes and the
//! websocket endpoint are skipped, exactly like kaya does.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use crate::http::{send_json, send_text};
use crate::platform::SharedPlatform;
pub const TITLE: &str = "tavolo";
pub const VERSION: &str = "0.1.0";
pub const DESCRIPTION: &str = "Multiplayer card-game platform API";
pub const SPEC_PATH: &str = "/api/openapi.json";
pub const DOCS_PATH: &str = "/api/docs";
fn query_parameter(name: &str, description: &str, schema: Value) -> Value {
json!({
"name": name,
"in": "query",
"required": false,
"schema": schema,
"description": description,
})
}
fn game_type_parameter() -> Value {
query_parameter(
"game_type",
"Only count matches of this game (id from GET /api/game-types).",
json!({"type": "string"}),
)
}
fn game_id_parameter() -> Value {
json!({
"name": "game_id",
"in": "path",
"required": true,
"schema": {"type": "string"},
})
}
/// The OpenAPI 3.1 document for the platform API.
pub fn spec() -> Value {
json!({
"openapi": "3.1.0",
"info": {
"title": TITLE,
"version": VERSION,
"description": DESCRIPTION,
},
"paths": {
"/api/health": {
"get": {
"summary": "Health check",
"tags": ["health"],
"responses": {"200": {"description": "The service is up"}},
}
},
"/api/me": {
"get": {
"summary": "Current user",
"description": "Returns the authenticated user's identity from the session; 401 when not logged in.",
"tags": ["auth"],
"responses": {
"200": {"description": "The current user"},
"401": {"description": "Not logged in"},
},
}
},
"/api/auth/providers": {
"get": {
"summary": "Configured login providers",
"description": "Names and login URLs of the configured OIDC providers, in configuration order.",
"tags": ["auth"],
"responses": {"200": {"description": "The configured providers"}},
}
},
"/auth/{provider}/login": {
"get": {
"summary": "Start an OIDC login",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider"}},
}
},
"/auth/{provider}/callback": {
"get": {
"summary": "OIDC login callback",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {
"302": {"description": "Redirect to the post-login target"},
"400": {"description": "Authentication failed"},
},
}
},
"/auth/{provider}/logout": {
"get": {
"summary": "Log out of an OIDC provider",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider or the SPA root"}},
}
},
"/api/game-types": {
"get": {
"summary": "List available games",
"description": "Every game the platform can host, for the match-creation dropdown. ``options_schema`` describes the per-game creation options accepted by POST /api/games.",
"tags": ["games"],
"responses": {"200": {"description": "The available game types"}},
}
},
"/api/games": {
"post": {
"summary": "Create a game",
"description": "Creates a lobby game and seats the caller in seat 0. Share the returned join_code with the other players. ``options`` is an opaque object validated by the chosen game (see GET /api/game-types).",
"tags": ["games"],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"description": "One of the ids from GET /api/game-types; defaults to the platform's first registered game",
},
"options": {
"type": "object",
"description": "Game-specific creation options",
},
},
}
}
}
},
"responses": {
"201": {"description": "The created lobby"},
"400": {"description": "Invalid game_type, options or body"},
"401": {"description": "Authentication required"},
},
}
},
"/api/games/join": {
"post": {
"summary": "Join a game by code",
"description": "Seats the caller in the next free chair. Joining as the last player starts the match.",
"tags": ["games"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
}
},
"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"},
},
}
},
"/api/games/preview": {
"get": {
"summary": "Preview a game by join code",
"description": "Looks up a game by its join code without joining, for the lobby's pre-join summary: game name and description, seated players, open seats and the game-specific options echoed by its engine.",
"tags": ["games"],
"responses": {
"200": {"description": "The lobby preview"},
"400": {"description": "Missing code"},
"401": {"description": "Authentication required"},
"404": {"description": "Unknown join code"},
},
}
},
"/api/games/{game_id}": {
"get": {
"summary": "Get a game snapshot",
"description": "Only seated players may read a game; the view is personalized by the engine (e.g. hidden hands).",
"tags": ["games"],
"parameters": [game_id_parameter()],
"responses": {
"200": {"description": "The personalized game state"},
"401": {"description": "Authentication required"},
"403": {"description": "Not a player in this game"},
"404": {"description": "Game not found"},
},
}
},
"/api/me/matches": {
"get": {
"summary": "List my matches",
"description": "Cursor-paginated history of finished matches the caller played, newest first, with the result.",
"tags": ["stats"],
"parameters": [
query_parameter(
"limit",
"Maximum number of results per page (clamped to [1, 100]).",
json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 20}),
),
query_parameter(
"cursor",
"Opaque pagination cursor from a previous response's next_cursor.",
json!({"type": "string"}),
),
game_type_parameter(),
],
"responses": {
"200": {"description": "A page of matches"},
"400": {"description": "Invalid pagination cursor or game_type"},
"401": {"description": "Authentication required"},
},
}
},
"/api/leaderboard": {
"get": {
"summary": "Global leaderboard",
"description": "Elo rating, aggregated wins, matches played and points for every player with at least one finished match. Sorted by Elo rating (the rating for the requested game_type, or the default game when the filter is absent).",
"tags": ["stats"],
"parameters": [game_type_parameter()],
"responses": {
"200": {"description": "The leaderboard"},
"400": {"description": "Unknown game_type"},
},
}
},
"/api/me/ratings": {
"get": {
"summary": "My Elo ratings",
"description": "The caller's current Elo rating for every game type they have played.",
"tags": ["stats"],
"responses": {
"200": {"description": "The caller's ratings"},
"401": {"description": "Authentication required"},
},
}
}
}
})
}
/// The Swagger UI page (assets loaded from a CDN, like kaya-openapi).
pub fn docs_page() -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{TITLE} - API documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {{
window.ui = SwaggerUIBundle({{
url: '{SPEC_PATH}',
dom_id: '#swagger-ui',
}});
}};
</script>
</body>
</html>
"#
)
}
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route(SPEC_PATH, get(spec_handler))
.route(DOCS_PATH, get(docs_handler))
}
async fn spec_handler(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &spec())
}
async fn docs_handler(State(_platform): State<SharedPlatform>) -> Response {
send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8")
}
@@ -0,0 +1,165 @@
//! Player statistics endpoints, served from the stats repository.
//!
//! Every finished match is persisted by
//! [`StatsRepo::save_match`](crate::stats::StatsRepo::save_match). These
//! endpoints expose a player's own match history and a global leaderboard
//! aggregated from the same tables. The game-specific outcome of each match
//! is exposed verbatim through the `result` JSON column.
use axum::extract::{RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use crate::auth::current_user;
use crate::http::{extract_query_params, py_repr, send_error, send_json, unauthorized};
use crate::pagination::{parse_cursor_params, CursorDecodeError};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/me/matches", get(my_matches))
.route("/api/me/ratings", get(my_ratings))
.route("/api/leaderboard", get(leaderboard))
}
fn parse_game_type(
platform: &Platform,
params: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Option<String>, Response> {
let Some(game_type) = params.get("game_type").and_then(|values| values.first()) else {
return Ok(None);
};
if platform.registry.get(game_type).is_none() {
return Err(send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(game_type.clone()))
),
));
}
Ok(Some(game_type.clone()))
}
fn cursor_error(error: CursorDecodeError) -> Response {
send_error(StatusCode::BAD_REQUEST, error.to_string())
}
/// Cursor-paginated history of finished matches the caller played.
async fn my_matches(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let cursor = match parse_cursor_params(&params) {
Ok(cursor) => cursor,
Err(err) => return cursor_error(err),
};
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform
.stats
.my_matches(
&user.sub,
game_type.as_deref(),
cursor.limit,
cursor.after.as_ref(),
)
.await
{
Ok((entries, next_cursor)) => {
let results: Vec<Value> = entries
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(
StatusCode::OK,
&json!({ "results": results, "next_cursor": next_cursor }),
)
}
Err(err) => {
tracing::error!("match history query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load matches")
}
}
}
/// Global leaderboard, sorted by Elo (the rating for the requested
/// game_type, or the default game when the filter is absent).
async fn leaderboard(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let rating_game = match &game_type {
Some(game_type) => game_type.clone(),
None => match platform.registry.default_engine() {
Ok(engine) => engine.id().to_string(),
Err(err) => {
tracing::error!("leaderboard default game lookup failed: {err}");
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
);
}
},
};
match platform
.stats
.leaderboard(game_type.as_deref(), &rating_game)
.await
{
Ok(ranking) => {
let results: Vec<Value> = ranking
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("leaderboard query failed: {err}");
send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
)
}
}
}
/// The caller's current Elo rating for every game type they have played.
async fn my_ratings(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform.stats.my_ratings(&user.sub).await {
Ok(rows) => {
let results: Vec<Value> = rows
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("ratings query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load ratings")
}
}
}