use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::{json, Value}; /// Coherent error type for all handlers and helpers. Exactly one set of /// constructors, one IntoResponse, and the three owned From conversions /// (rusqlite, std::io, serde_json) live here and nowhere else. #[derive(Debug)] pub struct AppError { pub status: StatusCode, pub code: &'static str, pub message: String, pub details: Option, } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}: {}", self.code, self.message) } } impl std::error::Error for AppError {} impl AppError { pub fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { AppError { status, code, message: message.into(), details: None } } pub fn bad(code: &'static str, msg: impl Into) -> Self { Self::new(StatusCode::BAD_REQUEST, code, msg) } pub fn unauthorized(msg: impl Into) -> Self { Self::new(StatusCode::UNAUTHORIZED, "unauthorized", msg) } pub fn forbidden(msg: impl Into) -> Self { Self::new(StatusCode::FORBIDDEN, "forbidden", msg) } pub fn not_found(msg: impl Into) -> Self { Self::new(StatusCode::NOT_FOUND, "not_found", msg) } pub fn conflict(code: &'static str, msg: impl Into) -> Self { Self::new(StatusCode::CONFLICT, code, msg) } pub fn internal(msg: impl Into) -> Self { Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal", msg) } pub fn with_details(mut self, d: Value) -> Self { self.details = Some(d); self } pub fn with_code(mut self, code: &'static str) -> Self { self.code = code; self } } impl IntoResponse for AppError { fn into_response(self) -> Response { let mut e = json!({"code": self.code, "message": self.message}); if let Some(details) = self.details { e["details"] = details; } (self.status, Json(json!({"error": e}))).into_response() } } impl From for AppError { fn from(e: rusqlite::Error) -> Self { match &e { rusqlite::Error::QueryReturnedNoRows => AppError::not_found("resource not found"), _ => { tracing::error!("db error: {e}"); AppError::internal("internal storage error") } } } } impl From for AppError { fn from(e: std::io::Error) -> Self { tracing::error!("io error: {e}"); AppError::internal("internal filesystem error") } } impl From for AppError { fn from(e: serde_json::Error) -> Self { tracing::error!("json error: {e}"); AppError::internal("internal json error") } } pub type AppResult = Result; #[cfg(test)] mod tests { use super::*; #[test] fn constructors_map_to_expected_status_and_code() { let e = AppError::bad("validation", "x"); assert_eq!(e.status, StatusCode::BAD_REQUEST); assert_eq!(e.code, "validation"); assert_eq!(AppError::unauthorized("y").status, StatusCode::UNAUTHORIZED); assert_eq!(AppError::forbidden("y").status, StatusCode::FORBIDDEN); assert_eq!(AppError::not_found("y").status, StatusCode::NOT_FOUND); assert_eq!(AppError::conflict("stale", "y").status, StatusCode::CONFLICT); assert_eq!(AppError::internal("y").status, StatusCode::INTERNAL_SERVER_ERROR); let v = AppError::conflict("stale", "y") .with_code("dataset_not_ready") .with_details(json!({"warnings": [1]})); assert_eq!(v.code, "dataset_not_ready"); assert_eq!(v.details.expect("details")["warnings"], json!([1])); } #[tokio::test] async fn body_shape_is_error_code_message_details() { let mut resp = AppError::conflict("stale", "expected_generation out of date") .with_details(json!({"expected": 3})) .into_response(); let bytes = axum::body::to_bytes(std::mem::take(resp.body_mut()), 64 * 1024).await.unwrap(); let parsed: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(parsed["error"]["code"], "stale"); assert_eq!(parsed["error"]["message"], "expected_generation out of date"); assert_eq!(parsed["error"]["details"]["expected"], 3); let mut plain = AppError::internal("boom").into_response(); let plain: Value = serde_json::from_slice( &axum::body::to_bytes(std::mem::take(plain.body_mut()), 64 * 1024).await.unwrap(), ).unwrap(); assert!(plain["error"].get("details").is_none()); } #[test] fn from_impls_convert_without_duplicates() { let e: AppError = rusqlite::Error::QueryReturnedNoRows.into(); assert_eq!(e.status, StatusCode::NOT_FOUND); let e: AppError = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into(); assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); let e: AppError = serde_json::from_str::("{").unwrap_err().into(); assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); } }