feat(highscores): add SQLite Axum service

Add a small standalone Axum service for the shared anonymous top-ten table.
SQLite keeps the deployment self-contained, while one transaction inserts a
validated name and score and removes entries below the canonical top ten.
Expose a health endpoint and same-origin API, with an nginx proxy block and
run instructions beside the service. Keep the service outside the game crate
so native gameplay persistence remains unchanged.

Test Plan:
- `cargo test --manifest-path highscore-server/Cargo.toml` -- passed (3 tests)
- `cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- Live executable health, POST, and GET smoke test -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-29 15:50:04 +02:00
parent 8fe9431989
commit ec0fdfd6b4
7 changed files with 1043 additions and 2 deletions
+279
View File
@@ -0,0 +1,279 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use axum::{
Json,
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use rusqlite::{Connection, params, types::Type};
use serde::{Deserialize, Serialize};
const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore {
pub name: String,
pub score: u32,
}
#[derive(Debug)]
pub enum StoreError {
Database(rusqlite::Error),
Poisoned,
}
impl From<rusqlite::Error> for StoreError {
fn from(error: rusqlite::Error) -> Self {
Self::Database(error)
}
}
#[derive(Clone)]
pub struct HighScoreStore(Arc<Mutex<Connection>>);
impl HighScoreStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open(path)?)
}
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open_in_memory()?)
}
fn from_connection(connection: Connection) -> Result<Self, rusqlite::Error> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295)
);
CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);",
)?;
Ok(Self(Arc::new(Mutex::new(connection))))
}
pub fn list(&self) -> Result<Vec<HighScore>, StoreError> {
let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let mut statement = connection.prepare(
"SELECT name, score
FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1",
)?;
let scores = statement
.query_map([i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)], |row| {
let score: i64 = row.get(1)?;
let score = u32::try_from(score).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(1, Type::Integer, Box::new(error))
})?;
Ok(HighScore {
name: row.get(0)?,
score,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(scores)
}
pub fn submit(&self, entry: &HighScore) -> Result<Vec<HighScore>, StoreError> {
{
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?;
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![entry.name, i64::from(entry.score)],
)?;
transaction.execute(
"DELETE FROM high_scores
WHERE id NOT IN (
SELECT id FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1
)",
[i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)],
)?;
transaction.commit()?;
}
self.list()
}
}
#[derive(Debug, Deserialize)]
struct SubmitRequest {
name: String,
score: u32,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: &'static str,
}
fn invalid_request(message: &'static str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: message }),
)
.into_response()
}
fn validate_request(request: SubmitRequest) -> Result<HighScore, &'static str> {
let name = request.name.trim();
if name.is_empty() {
return Err("name must not be empty");
}
if name.chars().count() > MAX_NAME_CHARS {
return Err("name is too long");
}
if name.chars().any(char::is_control) {
return Err("name contains a control character");
}
Ok(HighScore {
name: name.to_owned(),
score: request.score,
})
}
async fn health() -> &'static str {
"ok"
}
async fn list_high_scores(State(store): State<HighScoreStore>) -> Response {
match store.list() {
Ok(scores) => Json(scores).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn submit_high_score(
State(store): State<HighScoreStore>,
Json(request): Json<SubmitRequest>,
) -> Response {
let entry = match validate_request(request) {
Ok(entry) => entry,
Err(message) => return invalid_request(message),
};
match store.submit(&entry) {
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub fn router(store: HighScoreStore) -> Router {
Router::new()
.route("/healthz", get(health))
.route(
"/api/highscores",
get(list_high_scores).post(submit_high_score),
)
.with_state(store)
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tempfile::tempdir;
use tower::ServiceExt;
use super::*;
async fn submit(app: &Router, name: &str, score: u32) -> StatusCode {
app.clone()
.oneshot(
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"name": name,
"score": score,
}))
.expect("request JSON should encode"),
))
.expect("request should build"),
)
.await
.expect("router should respond")
.status()
}
async fn list(app: &Router) -> Vec<HighScore> {
let response = app
.clone()
.oneshot(
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should read")
.to_bytes();
serde_json::from_slice(&body).expect("response should contain scores")
}
#[tokio::test]
async fn api_persists_and_keeps_the_top_ten() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
let store = HighScoreStore::open(&database).expect("database should open");
let app = router(store);
for score in 0..12 {
assert_eq!(
submit(&app, &format!("Player {score}"), score).await,
StatusCode::CREATED
);
}
let scores = list(&app).await;
assert_eq!(scores.len(), MAX_HIGH_SCORES);
assert_eq!(scores[0].score, 11);
assert_eq!(scores[9].score, 2);
drop(app);
let reopened = router(HighScoreStore::open(&database).expect("database should reopen"));
assert_eq!(list(&reopened).await, scores);
}
#[tokio::test]
async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
assert_eq!(submit(&app, "", 10).await, StatusCode::BAD_REQUEST);
assert_eq!(
submit(&app, "abcdefghijklmnopqrstuv", 10).await,
StatusCode::BAD_REQUEST
);
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn health_endpoint_is_available_for_nginx() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let response = app
.oneshot(
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
}
}