Skip to main content

autumn_web/
error.rs

1//! Framework error type and result alias.
2//!
3//! [`AutumnError`] wraps any `Error + Send + Sync` with an HTTP status code.
4//! The blanket [`From`] impl maps all errors to `500 Internal Server Error`,
5//! so the `?` operator works in handlers with zero ceremony.
6//!
7//! For non-500 cases, use the status refinement constructors:
8//!
9//! - [`AutumnError::not_found`] -- 404
10//! - [`AutumnError::bad_request`] -- 400
11//! - [`AutumnError::unprocessable`] -- 422
12//! - [`AutumnError::service_unavailable`] -- 503
13//! - [`AutumnError::with_status`] -- arbitrary status code
14//!
15//! For simple string messages without wrapping an error type:
16//!
17//! - [`AutumnError::not_found_msg`] -- 404 with a message
18//! - [`AutumnError::bad_request_msg`] -- 400 with a message
19//! - [`AutumnError::unprocessable_msg`] -- 422 with a message
20//! - [`AutumnError::service_unavailable_msg`] -- 503 with a message
21//!
22//! # Response format
23//!
24//! When an `AutumnError` is returned from a handler, it renders as JSON:
25//!
26//! ```json
27//! { "error": { "status": 404, "message": "user not found" } }
28//! ```
29//!
30//! # Examples
31//!
32//! ```rust
33//! use autumn_web::error::AutumnError;
34//! use http::StatusCode;
35//!
36//! // Blanket From impl: any Error becomes 500
37//! let err: AutumnError = std::io::Error::other("disk full").into();
38//! assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
39//!
40//! // Explicit status constructors
41//! let err = AutumnError::not_found(std::io::Error::other("no such user"));
42//! assert_eq!(err.status(), StatusCode::NOT_FOUND);
43//! ```
44
45use axum::http::StatusCode;
46use axum::response::{IntoResponse, Response};
47use serde::Serialize;
48
49/// Simple error type wrapping a string message.
50///
51/// Used by the `_msg` convenience constructors on [`AutumnError`] so callers
52/// don't need to wrap strings in `std::io::Error`.
53#[derive(Debug)]
54struct StringError(String);
55
56impl std::fmt::Display for StringError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62impl std::error::Error for StringError {}
63
64/// Typed JSON body for error responses -- avoids dynamic `serde_json::Value`.
65#[derive(Serialize)]
66struct ErrorBody {
67    error: ErrorInner,
68}
69
70#[derive(Serialize)]
71struct ErrorInner {
72    status: u16,
73    message: String,
74}
75
76/// Framework error type wrapping any error with an HTTP status code.
77///
78/// # Usage
79///
80/// The `?` operator converts any `std::error::Error` into an `AutumnError`
81/// with status `500 Internal Server Error`:
82///
83/// ```rust,no_run
84/// use autumn_web::prelude::*;
85///
86/// #[get("/")]
87/// async fn handler() -> AutumnResult<&'static str> {
88///     std::fs::read_to_string("missing.txt")?; // becomes 500 on error
89///     Ok("ok")
90/// }
91/// ```
92///
93/// For expected errors, use a status refinement constructor:
94///
95/// ```rust,no_run
96/// use autumn_web::prelude::*;
97///
98/// #[get("/users/{id}")]
99/// async fn get_user(axum::extract::Path(id): axum::extract::Path<i32>) -> AutumnResult<String> {
100///     if id < 0 {
101///         return Err(AutumnError::bad_request(
102///             std::io::Error::other("id must be positive"),
103///         ));
104///     }
105///     Ok(format!("user {id}"))
106/// }
107/// ```
108///
109/// # Why no `Error` impl
110///
111/// `AutumnError` intentionally does **not** implement [`std::error::Error`].
112/// Doing so would conflict with the blanket `From<E: Error>` impl (the
113/// reflexive `From<T> for T` would overlap). This type is a *response*
114/// wrapper, not a propagatable error.
115pub struct AutumnError {
116    inner: Box<dyn std::error::Error + Send + Sync>,
117    status: StatusCode,
118}
119
120/// Convenience alias -- the standard return type for Autumn handlers.
121///
122/// Equivalent to `Result<T, AutumnError>`. Use this as the return type
123/// for any handler that might fail.
124///
125/// # Examples
126///
127/// ```rust,no_run
128/// use autumn_web::prelude::*;
129///
130/// #[get("/")]
131/// async fn index() -> AutumnResult<&'static str> {
132///     Ok("hello")
133/// }
134/// ```
135pub type AutumnResult<T> = Result<T, AutumnError>;
136
137impl<E> From<E> for AutumnError
138where
139    E: std::error::Error + Send + Sync + 'static,
140{
141    fn from(err: E) -> Self {
142        Self {
143            inner: Box::new(err),
144            status: StatusCode::INTERNAL_SERVER_ERROR,
145        }
146    }
147}
148
149impl AutumnError {
150    /// Override the HTTP status code.
151    ///
152    /// # Examples
153    ///
154    /// ```rust
155    /// use autumn_web::error::AutumnError;
156    /// use http::StatusCode;
157    ///
158    /// let err: AutumnError = std::io::Error::other("forbidden").into();
159    /// let err = err.with_status(StatusCode::FORBIDDEN);
160    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
161    /// ```
162    #[must_use]
163    pub const fn with_status(mut self, status: StatusCode) -> Self {
164        self.status = status;
165        self
166    }
167
168    /// Create a `404 Not Found` error.
169    ///
170    /// # Examples
171    ///
172    /// ```rust
173    /// use autumn_web::error::AutumnError;
174    /// use http::StatusCode;
175    ///
176    /// let err = AutumnError::not_found(std::io::Error::other("no such user"));
177    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
178    /// ```
179    pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
180        Self {
181            inner: Box::new(err),
182            status: StatusCode::NOT_FOUND,
183        }
184    }
185
186    /// Create a `400 Bad Request` error.
187    ///
188    /// # Examples
189    ///
190    /// ```rust
191    /// use autumn_web::error::AutumnError;
192    /// use http::StatusCode;
193    ///
194    /// let err = AutumnError::bad_request(std::io::Error::other("invalid input"));
195    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
196    /// ```
197    pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
198        Self {
199            inner: Box::new(err),
200            status: StatusCode::BAD_REQUEST,
201        }
202    }
203
204    /// Create a `422 Unprocessable Entity` error.
205    ///
206    /// Use this for validation failures where the request is syntactically
207    /// valid but semantically incorrect.
208    ///
209    /// # Examples
210    ///
211    /// ```rust
212    /// use autumn_web::error::AutumnError;
213    /// use http::StatusCode;
214    ///
215    /// let err = AutumnError::unprocessable(std::io::Error::other("age must be positive"));
216    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
217    /// ```
218    pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
219        Self {
220            inner: Box::new(err),
221            status: StatusCode::UNPROCESSABLE_ENTITY,
222        }
223    }
224
225    /// Create a `503 Service Unavailable` error.
226    ///
227    /// # Examples
228    ///
229    /// ```rust
230    /// use autumn_web::error::AutumnError;
231    /// use http::StatusCode;
232    ///
233    /// let err = AutumnError::service_unavailable(std::io::Error::other("pool exhausted"));
234    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
235    /// ```
236    pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
237        Self {
238            inner: Box::new(err),
239            status: StatusCode::SERVICE_UNAVAILABLE,
240        }
241    }
242
243    // ── String-message convenience constructors ────────────────
244
245    /// Create a `404 Not Found` error from a plain string message.
246    pub fn not_found_msg(msg: impl Into<String>) -> Self {
247        Self::not_found(StringError(msg.into()))
248    }
249
250    /// Create a `400 Bad Request` error from a plain string message.
251    pub fn bad_request_msg(msg: impl Into<String>) -> Self {
252        Self::bad_request(StringError(msg.into()))
253    }
254
255    /// Create a `422 Unprocessable Entity` error from a plain string message.
256    pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
257        Self::unprocessable(StringError(msg.into()))
258    }
259
260    /// Create a `503 Service Unavailable` error from a plain string message.
261    pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
262        Self::service_unavailable(StringError(msg.into()))
263    }
264
265    /// Returns the HTTP status code associated with this error.
266    ///
267    /// # Examples
268    ///
269    /// ```rust
270    /// use autumn_web::error::AutumnError;
271    /// use http::StatusCode;
272    ///
273    /// let err: AutumnError = std::io::Error::other("boom").into();
274    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
275    /// ```
276    #[must_use]
277    pub const fn status(&self) -> StatusCode {
278        self.status
279    }
280}
281
282impl std::fmt::Display for AutumnError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        write!(f, "{}", self.inner)
285    }
286}
287
288impl std::fmt::Debug for AutumnError {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("AutumnError")
291            .field("status", &self.status)
292            .field("inner", &self.inner)
293            .finish()
294    }
295}
296
297impl IntoResponse for AutumnError {
298    fn into_response(self) -> Response {
299        let status = self.status;
300        let body = ErrorBody {
301            error: ErrorInner {
302                status: status.as_u16(),
303                message: self.inner.to_string(),
304            },
305        };
306
307        (status, axum::Json(body)).into_response()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use axum::http::StatusCode;
315
316    #[derive(Debug)]
317    struct TestError(String);
318
319    impl std::fmt::Display for TestError {
320        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321            write!(f, "{}", self.0)
322        }
323    }
324
325    impl std::error::Error for TestError {}
326
327    #[test]
328    fn blanket_from_defaults_to_500() {
329        let err: AutumnError = TestError("boom".into()).into();
330        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
331    }
332
333    #[test]
334    fn not_found_is_404() {
335        let err = AutumnError::not_found(TestError("missing".into()));
336        assert_eq!(err.status(), StatusCode::NOT_FOUND);
337    }
338
339    #[test]
340    fn bad_request_is_400() {
341        let err = AutumnError::bad_request(TestError("invalid input".into()));
342        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
343    }
344
345    #[test]
346    fn unprocessable_is_422() {
347        let err = AutumnError::unprocessable(TestError("bad entity".into()));
348        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
349    }
350
351    #[test]
352    fn service_unavailable_is_503() {
353        let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
354        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
355    }
356
357    #[test]
358    fn not_found_msg_is_404() {
359        let err = AutumnError::not_found_msg("no such user");
360        assert_eq!(err.status(), StatusCode::NOT_FOUND);
361        assert_eq!(err.to_string(), "no such user");
362    }
363
364    #[test]
365    fn bad_request_msg_is_400() {
366        let err = AutumnError::bad_request_msg("invalid input");
367        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
368    }
369
370    #[test]
371    fn unprocessable_msg_is_422() {
372        let err = AutumnError::unprocessable_msg("title required");
373        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
374    }
375
376    #[test]
377    fn service_unavailable_msg_is_503() {
378        let err = AutumnError::service_unavailable_msg("db down");
379        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
380        assert_eq!(err.to_string(), "db down");
381    }
382
383    #[test]
384    fn with_status_overrides() {
385        let err: AutumnError = TestError("forbidden".into()).into();
386        let err = err.with_status(StatusCode::FORBIDDEN);
387        assert_eq!(err.status(), StatusCode::FORBIDDEN);
388    }
389
390    #[test]
391    fn display_uses_inner_message() {
392        let err: AutumnError = TestError("something broke".into()).into();
393        assert_eq!(err.to_string(), "something broke");
394    }
395
396    #[test]
397    fn into_response_has_correct_status() {
398        let err = AutumnError::not_found(TestError("not found".into()));
399        let response = err.into_response();
400        assert_eq!(response.status(), StatusCode::NOT_FOUND);
401    }
402
403    #[tokio::test]
404    async fn into_response_has_json_body() {
405        let err = AutumnError::not_found(TestError("not found".into()));
406        let response = err.into_response();
407
408        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
409            .await
410            .unwrap();
411        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
412
413        assert_eq!(json["error"]["status"], 404);
414        assert_eq!(json["error"]["message"], "not found");
415    }
416
417    #[test]
418    fn debug_shows_status_and_inner() {
419        let err = AutumnError::bad_request(TestError("oops".into()));
420        let debug = format!("{err:?}");
421        assert!(debug.contains("AutumnError"));
422        assert!(debug.contains("400"));
423    }
424
425    #[tokio::test]
426    async fn msg_constructor_produces_valid_json_response() {
427        let err = AutumnError::unprocessable_msg("title required");
428        let response = err.into_response();
429
430        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
431        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
432            .await
433            .unwrap();
434        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
435        assert_eq!(json["error"]["status"], 422);
436        assert_eq!(json["error"]["message"], "title required");
437    }
438
439    #[tokio::test]
440    async fn service_unavailable_response_is_503() {
441        let err = AutumnError::service_unavailable_msg("db down");
442        let response = err.into_response();
443
444        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
445        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
446            .await
447            .unwrap();
448        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
449        assert_eq!(json["error"]["status"], 503);
450        assert_eq!(json["error"]["message"], "db down");
451    }
452}