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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user