//! REST client for the tavolo backend. Same-origin requests carry the //! session cookie automatically. use crate::auth; use crate::model::*; use gloo_net::http::{Request, Response}; fn server_error(status: u16) -> String { format!("server returned {status}") } /// Map a failed response to an error. A `401` additionally starts the /// login flow: the session is gone (or the caller was never logged in), /// and the user is returned to this page after logging in. fn check(resp: &Response) -> Result<(), String> { if resp.status() == 401 { auth::redirect_to_login(); return Err("login required".to_string()); } if !resp.ok() { return Err(server_error(resp.status())); } Ok(()) } /// Fetch the current user; `None` when unauthenticated (401). /// /// Deliberately bypasses [`check`]: an unauthenticated answer is how the /// app renders its logged-out state, so it must not trigger the login /// redirect. pub async fn me() -> Result, String> { let resp = Request::get("/api/me") .send() .await .map_err(|e| e.to_string())?; if resp.status() == 401 { return Ok(None); } if !resp.ok() { return Err(server_error(resp.status())); } resp.json().await.map(Some).map_err(|e| e.to_string()) } /// Fetch the configured OIDC login providers (public endpoint). pub async fn auth_providers() -> Result, String> { let resp = Request::get("/api/auth/providers") .send() .await .map_err(|e| e.to_string())?; check(&resp)?; let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.providers) } /// Fetch the card games the platform can host (for the creation dropdown). pub async fn game_types() -> Result, String> { let resp = Request::get("/api/game-types") .send() .await .map_err(|e| e.to_string())?; check(&resp)?; let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.results) } /// Create a lobby game. `options` is the game-specific creation object /// described by the engine's `options_schema` (see /// [`crate::model::GameTypeInfo::option_fields`]). pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result { let resp = Request::post("/api/games") .json(&serde_json::json!({ "game_type": game_type, "options": options })) .map_err(|e| e.to_string())? .send() .await .map_err(|e| e.to_string())?; check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } pub async fn join_game(code: &str) -> Result { let resp = Request::post("/api/games/join") .json(&serde_json::json!({ "code": code })) .map_err(|e| e.to_string())? .send() .await .map_err(|e| e.to_string())?; if resp.status() == 401 { auth::redirect_to_login(); return Err("login required".to_string()); } if !resp.ok() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); let message = body .get("error") .and_then(|e| e.as_str()) .map(str::to_string) .unwrap_or_else(|| server_error(resp.status())); return Err(message); } resp.json().await.map_err(|e| e.to_string()) } /// Look up a game by join code without joining, for the pre-join summary. /// `Ok(None)` means the code names no live game (404); other failures are /// `Err`. pub async fn join_preview(code: &str) -> Result, String> { let resp = Request::get(&format!("/api/games/preview?code={code}")) .send() .await .map_err(|e| e.to_string())?; if resp.status() == 404 { return Ok(None); } check(&resp)?; resp.json().await.map(Some).map_err(|e| e.to_string()) } /// Why a game connection failed before the first snapshot arrived. /// /// A refused WebSocket handshake is invisible to the browser: the close /// event reports an abnormal closure (1006) with no HTTP status, so the /// game page asks the REST API to tell a deliberate rejection from a /// transient network problem. Anonymous users and non-players are /// welcome as spectators, so the only deliberate refusal is an unknown /// game. pub enum GameEntry { /// Unknown game (`404`): the id is wrong or the game's TTL expired. Unavailable, /// The API could not be asked (or answered unexpectedly): treat the /// connection loss as transient and retry. Transient, } /// Classify a failed game connection via `GET /api/games/{game_id}`, /// mirroring the status used to refuse the websocket upgrade. pub async fn probe_game(game_id: &str) -> GameEntry { match Request::get(&format!("/api/games/{game_id}")).send().await { Ok(resp) => match resp.status() { 404 => GameEntry::Unavailable, _ => GameEntry::Transient, }, Err(_) => GameEntry::Transient, } } /// Fetch the live games the caller is seated in, for the lobby's /// "Continue" list. pub async fn my_games() -> Result, String> { let resp = Request::get("/api/me/games") .send() .await .map_err(|e| e.to_string())?; check(&resp)?; let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.results) } pub async fn my_matches(cursor: Option<&str>) -> Result { let url = match cursor { Some(c) => format!("/api/me/matches?limit=10&cursor={c}"), None => "/api/me/matches?limit=10".to_string(), }; let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?; check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } /// Fetch the caller's Elo ratings (one row per game type played). pub async fn my_ratings() -> Result { let resp = Request::get("/api/me/ratings") .send() .await .map_err(|e| e.to_string())?; check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } /// Fetch the leaderboard for one game type: Elo, wins, matches and points /// are all scoped to that game. pub async fn leaderboard(game_type: &str) -> Result { let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}")) .send() .await .map_err(|e| e.to_string())?; check(&resp)?; resp.json().await.map_err(|e| e.to_string()) }