use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; use crate::storage::StorageError; #[derive(thiserror::Error, Debug)] pub enum AppError { #[error("not found")] NotFound, #[error("invalid input: {0}")] InvalidInput(String), #[error("unauthenticated")] Unauthenticated, #[error("forbidden")] Forbidden, #[error("conflict: {0}")] Conflict(String), #[error("payload too large: {0}")] PayloadTooLarge(String), #[error("unsupported media type: {0}")] UnsupportedMediaType(String), /// Semantic per-field validation failure. `details` is rendered into the /// envelope so the client can highlight the bad field(s). #[error("validation failed")] ValidationFailed { message: String, details: serde_json::Value, }, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Storage(#[from] StorageError), #[error(transparent)] Other(#[from] anyhow::Error), } pub type AppResult = Result; 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::Unauthenticated => "unauthenticated", AppError::Forbidden => "forbidden", AppError::Conflict(_) => "conflict", AppError::PayloadTooLarge(_) => "payload_too_large", AppError::UnsupportedMediaType(_) => "unsupported_media_type", AppError::ValidationFailed { .. } => "validation_failed", 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, details) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string(), None), AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone(), None), AppError::Unauthenticated => { (StatusCode::UNAUTHORIZED, "unauthenticated".to_string(), None) } AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string(), None), AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone(), None), AppError::PayloadTooLarge(msg) => { (StatusCode::PAYLOAD_TOO_LARGE, msg.clone(), None) } AppError::UnsupportedMediaType(msg) => { (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None) } AppError::ValidationFailed { message, details } => ( StatusCode::UNPROCESSABLE_ENTITY, message.clone(), Some(details.clone()), ), AppError::Database(sqlx::Error::RowNotFound) => { (StatusCode::NOT_FOUND, "not found".to_string(), None) } AppError::Storage(StorageError::NotFound) => { (StatusCode::NOT_FOUND, "not found".to_string(), None) } AppError::Storage(StorageError::BadKey) => ( StatusCode::BAD_REQUEST, "invalid file key".to_string(), None, ), AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => { tracing::error!(error = ?self, "internal error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), None, ) } }; let body = match details { Some(d) => json!({ "error": { "code": code, "message": message, "details": d } }), None => 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::Unauthenticated.code(), "unauthenticated"); assert_eq!(AppError::Forbidden.code(), "forbidden"); assert_eq!(AppError::Conflict("x".into()).code(), "conflict"); assert_eq!(AppError::PayloadTooLarge("x".into()).code(), "payload_too_large"); assert_eq!( AppError::UnsupportedMediaType("x".into()).code(), "unsupported_media_type" ); assert_eq!( AppError::ValidationFailed { message: "x".into(), details: json!({}), } .code(), "validation_failed" ); 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"); } }