use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; #[derive(Debug, Serialize)] struct ErrorEnvelope { error: ErrorBody, } #[derive(Debug, Serialize)] struct ErrorBody { code: String, message: String, status: u16, } #[derive(Debug)] #[allow(dead_code)] pub enum AppError { InternalError(String), NotFound(String), Unauthorized(String), BadRequest(String), Forbidden(String), ServiceUnavailable(String), PayloadTooLarge(String), } impl AppError { fn status_code(&self) -> StatusCode { match self { AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::NotFound(_) => StatusCode::NOT_FOUND, AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::Forbidden(_) => StatusCode::FORBIDDEN, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, } } fn error_code(&self) -> &'static str { match self { AppError::InternalError(_) => "INTERNAL_ERROR", AppError::NotFound(_) => "NOT_FOUND", AppError::Unauthorized(_) => "UNAUTHORIZED", AppError::BadRequest(_) => "BAD_REQUEST", AppError::Forbidden(_) => "FORBIDDEN", AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE", AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE", } } fn message(&self) -> &str { match self { AppError::InternalError(msg) | AppError::NotFound(msg) | AppError::Unauthorized(msg) | AppError::BadRequest(msg) | AppError::Forbidden(msg) | AppError::ServiceUnavailable(msg) | AppError::PayloadTooLarge(msg) => msg, } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.status_code(); let envelope = ErrorEnvelope { error: ErrorBody { code: self.error_code().to_string(), message: self.message().to_string(), status: status.as_u16(), }, }; let body = serde_json::to_string(&envelope).unwrap_or_else(|_| { r#"{"error":{"code":"INTERNAL_ERROR","message":"Failed to serialize error","status":500}}"# .to_string() }); (status, [("content-type", "application/json")], body).into_response() } } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "[{}] {}", self.error_code(), self.message()) } } #[cfg(test)] mod tests { use super::*; use http_body_util::BodyExt; async fn error_to_json(error: AppError) -> (StatusCode, serde_json::Value) { let response = error.into_response(); let status = response.status(); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).expect("body should be valid JSON"); (status, json) } #[tokio::test] async fn internal_error_returns_500() { let (status, json) = error_to_json(AppError::InternalError("something broke".into())).await; assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); assert_eq!(json["error"]["message"], "something broke"); assert_eq!(json["error"]["status"], 500); } #[tokio::test] async fn not_found_returns_404() { let (status, json) = error_to_json(AppError::NotFound("mailbox missing".into())).await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(json["error"]["code"], "NOT_FOUND"); assert_eq!(json["error"]["message"], "mailbox missing"); assert_eq!(json["error"]["status"], 404); } #[tokio::test] async fn unauthorized_returns_401() { let (status, json) = error_to_json(AppError::Unauthorized("bad token".into())).await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(json["error"]["code"], "UNAUTHORIZED"); assert_eq!(json["error"]["message"], "bad token"); assert_eq!(json["error"]["status"], 401); } #[tokio::test] async fn bad_request_returns_400() { let (status, json) = error_to_json(AppError::BadRequest("invalid input".into())).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(json["error"]["code"], "BAD_REQUEST"); assert_eq!(json["error"]["message"], "invalid input"); assert_eq!(json["error"]["status"], 400); } #[tokio::test] async fn forbidden_returns_403() { let (status, json) = error_to_json(AppError::Forbidden("blocked".into())).await; assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(json["error"]["code"], "FORBIDDEN"); assert_eq!(json["error"]["status"], 403); } #[tokio::test] async fn payload_too_large_returns_413() { let (status, json) = error_to_json(AppError::PayloadTooLarge("message too large".into())).await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(json["error"]["code"], "PAYLOAD_TOO_LARGE"); assert_eq!(json["error"]["status"], 413); } #[tokio::test] async fn service_unavailable_returns_503() { let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); assert_eq!(json["error"]["message"], "IMAP down"); assert_eq!(json["error"]["status"], 503); } }