Skip to main content

aro_web/
error.rs

1//! Error types and JSON error response mapping for the Aro web framework.
2//!
3//! [`AroError`] provides a unified error type that bridges domain errors
4//! (from [`aro_core::error::RepoError`]) to HTTP responses with consistent
5//! JSON error bodies and appropriate status codes.
6//!
7//! Actions can return `Result<Json<T>, AroError>` for ergonomic error handling:
8//!
9//! ```ignore
10//! async fn get_user(Path(id): Path<u64>, repo: Dep<dyn UserRepo>) -> Result<Json<User>, AroError> {
11//!     let user = repo.find_by_id(id).await?; // RepoError -> AroError via From
12//!     Ok(Json(user))
13//! }
14//! ```
15
16use std::error::Error;
17
18use aro_core::error::RepoError;
19use axum::http::StatusCode;
20use axum::response::{IntoResponse, Response};
21
22use crate::fallback::json_error_response;
23
24/// Unified error type for Aro web actions.
25///
26/// Each variant maps to an HTTP status code and produces a JSON error
27/// response body of the form `{"error": "message", "status": <code>}`.
28#[derive(Debug)]
29pub enum AroError {
30    /// The requested resource was not found (404).
31    NotFound,
32    /// A conflicting resource already exists (409).
33    Conflict,
34    /// A validation rule was violated (422).
35    ValidationError(String),
36    /// The request was malformed (400).
37    BadRequest(String),
38    /// The request lacks valid authentication credentials (401).
39    Unauthorized(String),
40    /// The authenticated user does not have permission (403).
41    Forbidden(String),
42    /// An unexpected internal error occurred (500).
43    Internal(Box<dyn Error + Send + Sync>),
44}
45
46impl AroError {
47    /// Returns the HTTP status code for this error.
48    pub fn status_code(&self) -> StatusCode {
49        match self {
50            Self::NotFound => StatusCode::NOT_FOUND,
51            Self::Conflict => StatusCode::CONFLICT,
52            Self::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
53            Self::BadRequest(_) => StatusCode::BAD_REQUEST,
54            Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
55            Self::Forbidden(_) => StatusCode::FORBIDDEN,
56            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
57        }
58    }
59
60    /// Returns the user-facing error message for this error.
61    pub fn message(&self) -> String {
62        match self {
63            Self::NotFound => "not found".to_string(),
64            Self::Conflict => "conflict".to_string(),
65            Self::ValidationError(msg)
66            | Self::BadRequest(msg)
67            | Self::Unauthorized(msg)
68            | Self::Forbidden(msg) => msg.clone(),
69            Self::Internal(_) => "internal server error".to_string(),
70        }
71    }
72}
73
74impl IntoResponse for AroError {
75    fn into_response(self) -> Response {
76        if let Self::Internal(ref err) = self {
77            tracing::error!(error = %err, "internal server error");
78        }
79        json_error_response(self.status_code(), self.message())
80    }
81}
82
83impl From<RepoError> for AroError {
84    fn from(err: RepoError) -> Self {
85        match err {
86            RepoError::NotFound => Self::NotFound,
87            RepoError::Conflict => Self::Conflict,
88            RepoError::ValidationError(msg) => Self::ValidationError(msg),
89            RepoError::Unknown(err) => Self::Internal(err),
90        }
91    }
92}
93
94impl std::fmt::Display for AroError {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::NotFound => write!(f, "not found"),
98            Self::Conflict => write!(f, "conflict"),
99            Self::ValidationError(msg) => write!(f, "validation error: {msg}"),
100            Self::BadRequest(msg) => write!(f, "bad request: {msg}"),
101            Self::Unauthorized(msg) => write!(f, "unauthorized: {msg}"),
102            Self::Forbidden(msg) => write!(f, "forbidden: {msg}"),
103            Self::Internal(err) => write!(f, "internal error: {err}"),
104        }
105    }
106}
107
108impl Error for AroError {
109    fn source(&self) -> Option<&(dyn Error + 'static)> {
110        match self {
111            Self::Internal(err) => Some(err.as_ref()),
112            _ => None,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use http_body_util::BodyExt;
121    use tracing_test::traced_test;
122
123    async fn response_json(resp: Response) -> serde_json::Value {
124        let body = resp.into_body();
125        let bytes = body.collect().await.unwrap().to_bytes();
126        serde_json::from_slice(&bytes).unwrap()
127    }
128
129    #[tokio::test]
130    async fn not_found_produces_404_json() {
131        let resp = AroError::NotFound.into_response();
132        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
133        assert_eq!(
134            resp.headers().get("content-type").unwrap(),
135            "application/json"
136        );
137        let json = response_json(resp).await;
138        assert_eq!(json["error"], "not found");
139        assert_eq!(json["status"], 404);
140    }
141
142    #[tokio::test]
143    async fn conflict_produces_409_json() {
144        let resp = AroError::Conflict.into_response();
145        assert_eq!(resp.status(), StatusCode::CONFLICT);
146        let json = response_json(resp).await;
147        assert_eq!(json["error"], "conflict");
148        assert_eq!(json["status"], 409);
149    }
150
151    #[tokio::test]
152    async fn validation_error_produces_422_json() {
153        let resp = AroError::ValidationError("name is required".into()).into_response();
154        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
155        let json = response_json(resp).await;
156        assert_eq!(json["error"], "name is required");
157        assert_eq!(json["status"], 422);
158    }
159
160    #[tokio::test]
161    async fn bad_request_produces_400_json() {
162        let resp = AroError::BadRequest("invalid id".into()).into_response();
163        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
164        let json = response_json(resp).await;
165        assert_eq!(json["error"], "invalid id");
166        assert_eq!(json["status"], 400);
167    }
168
169    #[tokio::test]
170    async fn internal_produces_500_json() {
171        let inner = std::io::Error::other("db crashed");
172        let resp = AroError::Internal(Box::new(inner)).into_response();
173        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
174        let json = response_json(resp).await;
175        assert_eq!(json["error"], "internal server error");
176        assert_eq!(json["status"], 500);
177    }
178
179    #[tokio::test]
180    #[traced_test]
181    async fn internal_logs_error_before_response() {
182        let inner = std::io::Error::other("db crashed");
183        let resp = AroError::Internal(Box::new(inner)).into_response();
184
185        assert!(logs_contain("internal server error"));
186        assert!(logs_contain("db crashed"));
187        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
188    }
189
190    #[test]
191    fn repo_not_found_converts_to_aro_not_found() {
192        let aro_err: AroError = RepoError::NotFound.into();
193        assert!(matches!(aro_err, AroError::NotFound));
194    }
195
196    #[test]
197    fn repo_conflict_converts_to_aro_conflict() {
198        let aro_err: AroError = RepoError::Conflict.into();
199        assert!(matches!(aro_err, AroError::Conflict));
200    }
201
202    #[test]
203    fn repo_validation_converts_to_aro_validation() {
204        let aro_err: AroError = RepoError::ValidationError("bad".into()).into();
205        assert!(matches!(aro_err, AroError::ValidationError(msg) if msg == "bad"));
206    }
207
208    #[test]
209    fn repo_unknown_converts_to_aro_internal() {
210        let inner = std::io::Error::other("oops");
211        let aro_err: AroError = RepoError::Unknown(Box::new(inner)).into();
212        assert!(matches!(aro_err, AroError::Internal(_)));
213    }
214
215    #[tokio::test]
216    async fn unauthorized_produces_401_json() {
217        let resp = AroError::Unauthorized("invalid token".into()).into_response();
218        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
219        let json = response_json(resp).await;
220        assert_eq!(json["error"], "invalid token");
221        assert_eq!(json["status"], 401);
222    }
223
224    #[tokio::test]
225    async fn forbidden_produces_403_json() {
226        let resp = AroError::Forbidden("insufficient permissions".into()).into_response();
227        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
228        let json = response_json(resp).await;
229        assert_eq!(json["error"], "insufficient permissions");
230        assert_eq!(json["status"], 403);
231    }
232
233    #[test]
234    fn unauthorized_display() {
235        let err = AroError::Unauthorized("bad token".into());
236        assert_eq!(err.to_string(), "unauthorized: bad token");
237    }
238
239    #[test]
240    fn forbidden_display() {
241        let err = AroError::Forbidden("not allowed".into());
242        assert_eq!(err.to_string(), "forbidden: not allowed");
243    }
244
245    #[tokio::test]
246    async fn error_response_body_matches_expected_structure() {
247        let resp = AroError::NotFound.into_response();
248        let json = response_json(resp).await;
249        // Verify only "error" and "status" keys exist
250        let obj = json.as_object().unwrap();
251        assert_eq!(obj.len(), 2);
252        assert!(obj.contains_key("error"));
253        assert!(obj.contains_key("status"));
254    }
255}