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:
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, ¶ms).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(¶ms) {
|
||||
Ok(cursor) => cursor,
|
||||
Err(err) => return cursor_error(err),
|
||||
};
|
||||
let game_type = match parse_game_type(&platform, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user