diff options
Diffstat (limited to 'server/src/error.rs')
| -rw-r--r-- | server/src/error.rs | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..0342afd --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,145 @@ +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<Value>, +} + +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<String>) -> Self { + AppError { status, code, message: message.into(), details: None } + } + pub fn bad(code: &'static str, msg: impl Into<String>) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, msg) + } + pub fn unauthorized(msg: impl Into<String>) -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized", msg) + } + pub fn forbidden(msg: impl Into<String>) -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden", msg) + } + pub fn not_found(msg: impl Into<String>) -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", msg) + } + pub fn conflict(code: &'static str, msg: impl Into<String>) -> Self { + Self::new(StatusCode::CONFLICT, code, msg) + } + pub fn internal(msg: impl Into<String>) -> 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<rusqlite::Error> 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<std::io::Error> for AppError { + fn from(e: std::io::Error) -> Self { + tracing::error!("io error: {e}"); + AppError::internal("internal filesystem error") + } +} + +impl From<serde_json::Error> for AppError { + fn from(e: serde_json::Error) -> Self { + tracing::error!("json error: {e}"); + AppError::internal("internal json error") + } +} + +pub type AppResult<T> = Result<T, AppError>; + +#[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::<Value>("{").unwrap_err().into(); + assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR); + } +} |
