feat(server): replay upload + fetch endpoints
API surface for the web replay viewer to come:
- `POST /api/replays` — auth required; persists the JSON body
verbatim, mints a server-side UUID, returns `{id}`. Three columns
(final_score, time_seconds, recorded_at) are projected out of the
payload at insert time so list endpoints don't have to scan blobs.
- `GET /api/replays/recent` — public; returns the N most-recent
replays across users (limit defaults to 20, capped at 50). Joins
the username so the feed reads as "AliceWon · 2:14 win".
- `GET /api/replays/:id` — public; returns the full replay JSON
the desktop client uploaded.
Migration `002_replays.sql` adds the `replays` table with indexes
on `received_at DESC` (recent feed) and `user_id` (per-user views).
Schema-version compatibility is the playback side's responsibility,
matching the desktop's existing `schema_version` gate — the server
just stores and serves whatever JSON came in.
`AppError::NotFound` added so `GET /api/replays/:id` can return a
proper 404 instead of an internal-server-error.
`.sqlx` cache regenerated for the new `query!` invocations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "seed!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "draw_mode!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mode!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "time_seconds!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "final_score!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "recorded_at!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "received_at!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT replay_json FROM replays WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "replay_json",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Migration 002: winning-replay storage
|
||||
--
|
||||
-- One row per winning replay uploaded via POST /api/replays. The replay
|
||||
-- itself is stored as the canonical JSON the desktop client wrote — it
|
||||
-- already carries a schema_version field, so the server doesn't need to
|
||||
-- shape-validate the payload beyond ensuring it parses as JSON.
|
||||
--
|
||||
-- The handful of denormalised columns (final_score, time_seconds,
|
||||
-- recorded_at) are projected out of the JSON at insert time so list
|
||||
-- endpoints (e.g. recent / per-user / leaderboard-style sorts) can be
|
||||
-- served via a covering query without touching every row's blob.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replays (
|
||||
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
seed INTEGER NOT NULL, -- replay's deal seed
|
||||
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||
time_seconds INTEGER NOT NULL, -- duration of the win
|
||||
final_score INTEGER NOT NULL, -- final score at the win
|
||||
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||
);
|
||||
|
||||
-- Recent-replays list endpoint sorts by received_at DESC; the index
|
||||
-- keeps that scan cheap on a populated table.
|
||||
CREATE INDEX IF NOT EXISTS replays_received_at_idx
|
||||
ON replays(received_at DESC);
|
||||
|
||||
-- Lookups by user (e.g. "my replays" view) are common too.
|
||||
CREATE INDEX IF NOT EXISTS replays_user_id_idx
|
||||
ON replays(user_id);
|
||||
@@ -31,6 +31,10 @@ pub enum AppError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// The requested resource does not exist.
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// A database error occurred.
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
@@ -56,6 +60,7 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod challenge;
|
||||
pub mod error;
|
||||
pub mod leaderboard;
|
||||
pub mod middleware;
|
||||
pub mod replays;
|
||||
pub mod sync;
|
||||
|
||||
use axum::{
|
||||
@@ -64,6 +65,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/sync/pull", get(sync::pull))
|
||||
.route("/api/sync/push", post(sync::push))
|
||||
.route("/api/replays", post(replays::upload))
|
||||
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||
@@ -98,6 +100,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
// Public endpoints (no auth, no rate limit beyond defaults).
|
||||
let public = Router::new()
|
||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||
.route("/api/replays/recent", get(replays::recent))
|
||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||
.route("/health", get(health));
|
||||
|
||||
Router::new()
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Winning-replay storage and retrieval.
|
||||
//!
|
||||
//! `POST /api/replays` — upload a winning replay (auth required).
|
||||
//! `GET /api/replays/recent` — list the N most-recent replays across users.
|
||||
//! `GET /api/replays/:id` — fetch a single replay's full JSON.
|
||||
//!
|
||||
//! The replay payload itself is opaque to the server — the desktop client
|
||||
//! generates a `solitaire_data::Replay` and the web playback re-executes
|
||||
//! the same atomic input list against a fresh `GameState`. The server
|
||||
//! just persists, indexes, and serves the JSON; it does not validate the
|
||||
//! semantics of the move list.
|
||||
//!
|
||||
//! Three columns are projected out of the replay JSON at insert time
|
||||
//! (`final_score`, `time_seconds`, `recorded_at`) so list endpoints can
|
||||
//! be served without scanning every blob.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subset of `Replay` fields the server needs to project out of the
|
||||
/// uploaded JSON to populate the denormalised columns. Mirrors the
|
||||
/// fields on `solitaire_data::Replay`; we don't depend on
|
||||
/// `solitaire_data` here because the server crate must not pull in
|
||||
/// the desktop client's transitive dependencies.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayHeader {
|
||||
seed: i64,
|
||||
draw_mode: String,
|
||||
mode: String,
|
||||
time_seconds: i64,
|
||||
final_score: i64,
|
||||
recorded_at: String,
|
||||
}
|
||||
|
||||
/// Successful upload acknowledgement. The server-minted `id` is what
|
||||
/// the client / web UI uses to link to `/replays/<id>`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplayUploadResponse {
|
||||
/// UUID v4 minted server-side at insert time.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// One row in the recent-replays list. Just the projection columns —
|
||||
/// the full move list lives behind `GET /api/replays/:id`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplaySummary {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub seed: i64,
|
||||
pub draw_mode: String,
|
||||
pub mode: String,
|
||||
pub time_seconds: i64,
|
||||
pub final_score: i64,
|
||||
pub recorded_at: String,
|
||||
pub received_at: String,
|
||||
}
|
||||
|
||||
/// `GET /api/replays/recent?limit=N` — bound the result set so a
|
||||
/// long-tail history doesn't ship megabytes per request.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecentQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/replays` — accept a winning replay JSON, persist it,
|
||||
/// return the server-minted `id`. Auth required (the upload is
|
||||
/// attributed to the authenticated user).
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<ReplayUploadResponse>, AppError> {
|
||||
// Project the header fields the SQL columns need. The full payload
|
||||
// is stored verbatim — schema_version sits inside it and the
|
||||
// playback path is what enforces compatibility.
|
||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let received_at = Utc::now().to_rfc3339();
|
||||
let replay_json = serde_json::to_string(&payload)?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO replays (
|
||||
id, user_id, seed, draw_mode, mode, time_seconds, final_score,
|
||||
recorded_at, received_at, replay_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
id,
|
||||
user.user_id,
|
||||
header.seed,
|
||||
header.draw_mode,
|
||||
header.mode,
|
||||
header.time_seconds,
|
||||
header.final_score,
|
||||
header.recorded_at,
|
||||
received_at,
|
||||
replay_json,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReplayUploadResponse { id }))
|
||||
}
|
||||
|
||||
/// `GET /api/replays/recent` — list the N most-recent replays across
|
||||
/// every user, newest first. Auth not required so the web UI can show
|
||||
/// a public "latest wins" feed without a logged-in client.
|
||||
pub async fn recent(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<RecentQuery>,
|
||||
) -> Result<Json<Vec<ReplaySummary>>, AppError> {
|
||||
// 50 is a sane upper bound so a `?limit=999999` request can't make
|
||||
// the server allocate megabytes. 20 is the default for a quick feed.
|
||||
let limit = q.limit.unwrap_or(20).min(50) as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
r.id AS "id!: String",
|
||||
u.username AS "username!: String",
|
||||
r.seed AS "seed!: i64",
|
||||
r.draw_mode AS "draw_mode!: String",
|
||||
r.mode AS "mode!: String",
|
||||
r.time_seconds AS "time_seconds!: i64",
|
||||
r.final_score AS "final_score!: i64",
|
||||
r.recorded_at AS "recorded_at!: String",
|
||||
r.received_at AS "received_at!: String"
|
||||
FROM replays r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
ORDER BY r.received_at DESC
|
||||
LIMIT ?"#,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|r| ReplaySummary {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
seed: r.seed,
|
||||
draw_mode: r.draw_mode,
|
||||
mode: r.mode,
|
||||
time_seconds: r.time_seconds,
|
||||
final_score: r.final_score,
|
||||
recorded_at: r.recorded_at,
|
||||
received_at: r.received_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/replays/:id` — return the full replay JSON the desktop
|
||||
/// client uploaded. Public; the web UI fetches this directly.
|
||||
///
|
||||
/// The server does not validate or transform the payload — what was
|
||||
/// stored is what's returned. Schema-version compatibility is the
|
||||
/// responsibility of the playback side (web UI), matching the
|
||||
/// `schema_version` gate the desktop loader uses.
|
||||
pub async fn get_by_id(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT replay_json FROM replays WHERE id = ?",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let replay_json = row
|
||||
.ok_or_else(|| AppError::NotFound("replay not found".into()))?
|
||||
.replay_json;
|
||||
let value: serde_json::Value = serde_json::from_str(&replay_json)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
Reference in New Issue
Block a user