93182fa251
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>
86 lines
2.9 KiB
Rust
86 lines
2.9 KiB
Rust
//! Application-level error type with automatic HTTP response conversion.
|
|
|
|
use axum::{
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use serde_json::json;
|
|
use thiserror::Error;
|
|
|
|
/// All errors that can be returned by the server.
|
|
///
|
|
/// Each variant maps to a specific HTTP status code when converted to a
|
|
/// response via [`IntoResponse`].
|
|
#[derive(Debug, Error)]
|
|
pub enum AppError {
|
|
/// The request is missing a valid `Authorization: Bearer` header, or the
|
|
/// JWT is expired / has an invalid signature.
|
|
#[error("unauthorized")]
|
|
Unauthorized,
|
|
|
|
/// The supplied credentials (username / password) were incorrect.
|
|
#[error("invalid credentials")]
|
|
InvalidCredentials,
|
|
|
|
/// The requested username is already registered.
|
|
#[error("username already taken")]
|
|
UsernameTaken,
|
|
|
|
/// The client sent a malformed or invalid request body.
|
|
#[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),
|
|
|
|
/// Password hashing failed.
|
|
#[error("internal server error")]
|
|
BcryptError(#[from] bcrypt::BcryptError),
|
|
|
|
/// JSON serialization / deserialization failed.
|
|
#[error("serialization error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
|
|
/// A catch-all for unexpected internal failures.
|
|
#[error("internal server error")]
|
|
Internal(String),
|
|
}
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let (status, message) = match &self {
|
|
AppError::Unauthorized | AppError::InvalidCredentials => {
|
|
(StatusCode::UNAUTHORIZED, self.to_string())
|
|
}
|
|
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())
|
|
}
|
|
AppError::BcryptError(e) => {
|
|
tracing::error!("bcrypt error: {e}");
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
|
}
|
|
AppError::Json(e) => {
|
|
tracing::error!("json error: {e}");
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
|
}
|
|
AppError::Internal(msg) => {
|
|
tracing::error!("internal error: {msg}");
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
|
}
|
|
};
|
|
|
|
let body = Json(json!({ "error": message }));
|
|
(status, body).into_response()
|
|
}
|
|
}
|