feat: nest API under /api/v1, structured error envelope, paged lists

Move every handler from /api/* to /api/v1/*. /api/* is now reserved for
future versioning.

Standardise the error response shape across the API as
{"error": {"code": "snake_case", "message": "..."}}. AppError gains a
`code()` whose top-level variants are matched exhaustively without a
wildcard — new variants are a compile error until coded. 500-class
responses always emit the fixed "internal error" string and log the
real cause via tracing only.

Lock in the list pagination envelope as {"items": [...], "page": {
"limit", "offset", "total"}} and apply it to GET /api/v1/mangas. `total`
serialises as null until feat/list-search-polish lands an indexed count.

The frontend client parses the envelope into ApiError.code with an
http_error fallback for non-JSON bodies. listMangas now returns the
paged shape; the root route consumes .items. New client.test.ts covers
envelope parsing and the fallback paths.

Lockstep version bump to 0.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 21:41:20 +02:00
parent 6c1d04aaf4
commit ce9a01793f
18 changed files with 6121 additions and 67 deletions

View File

@@ -21,11 +21,30 @@ pub enum AppError {
pub type AppResult<T> = Result<T, AppError>;
impl AppError {
/// Stable, snake_case code that clients pattern-match on. Every top-level
/// variant is matched explicitly — adding a new variant without giving it
/// a code is a compile error, on purpose.
pub fn code(&self) -> &'static str {
match self {
AppError::NotFound => "not_found",
AppError::InvalidInput(_) => "invalid_input",
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
AppError::Database(_) => "internal_error",
AppError::Storage(StorageError::NotFound) => "not_found",
AppError::Storage(StorageError::BadKey) => "bad_file_key",
AppError::Storage(StorageError::Io(_)) => "internal_error",
AppError::Other(_) => "internal_error",
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let code = self.code();
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not found".to_string())
}
@@ -40,6 +59,22 @@ impl IntoResponse for AppError {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
let body = json!({ "error": { "code": code, "message": message } });
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_are_stable() {
assert_eq!(AppError::NotFound.code(), "not_found");
assert_eq!(AppError::InvalidInput("x".into()).code(), "invalid_input");
assert_eq!(AppError::Storage(StorageError::BadKey).code(), "bad_file_key");
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
}
}