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::{HeaderValue, StatusCode, header};
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/// JSON body for RFC 7807 Problem Details responses.
65#[derive(Clone, Debug, Serialize)]
66pub struct ProblemDetails {
67    /// Problem type URI. Autumn uses stable `https://autumn.dev/problems/...`
68    /// URIs for framework-generated errors.
69    #[serde(rename = "type")]
70    pub type_uri: String,
71    /// Short human-readable title for the status/problem class.
72    pub title: String,
73    /// HTTP status code.
74    pub status: u16,
75    /// Client-safe human-readable explanation.
76    pub detail: String,
77    /// Request path or URI reference for the specific occurrence.
78    pub instance: Option<String>,
79    /// Stable machine-readable Autumn error code.
80    pub code: String,
81    /// Request ID for log correlation, when the request pipeline assigned one.
82    pub request_id: Option<String>,
83    /// Field-level validation failures. Empty for non-validation errors.
84    pub errors: Vec<ProblemFieldError>,
85}
86
87/// Field-level validation detail in the Problem Details `errors` extension.
88#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
89pub struct ProblemFieldError {
90    /// Field name as seen by the request payload or form.
91    pub field: String,
92    /// Stable list of validation messages for this field.
93    pub messages: Vec<String>,
94}
95
96/// Framework error type wrapping any error with an HTTP status code.
97///
98/// # Usage
99///
100/// The `?` operator converts any `std::error::Error` into an `AutumnError`
101/// with status `500 Internal Server Error`:
102///
103/// ```rust,no_run
104/// use autumn_web::prelude::*;
105///
106/// #[get("/")]
107/// async fn handler() -> AutumnResult<&'static str> {
108///     autumn_web::reexports::tokio::fs::read_to_string("missing.txt").await?; // becomes 500 on error
109///     Ok("ok")
110/// }
111/// ```
112///
113/// For expected errors, use a status refinement constructor:
114///
115/// ```rust,no_run
116/// use autumn_web::prelude::*;
117///
118/// #[get("/users/{id}")]
119/// async fn get_user(axum::extract::Path(id): axum::extract::Path<i32>) -> AutumnResult<String> {
120///     if id < 0 {
121///         return Err(AutumnError::bad_request(
122///             std::io::Error::other("id must be positive"),
123///         ));
124///     }
125///     Ok(format!("user {id}"))
126/// }
127/// ```
128///
129/// # Why no `Error` impl
130///
131/// `AutumnError` intentionally does **not** implement [`std::error::Error`].
132/// Doing so would conflict with the blanket `From<E: Error>` impl (the
133/// reflexive `From<T> for T` would overlap). This type is a *response*
134/// wrapper, not a propagatable error.
135pub struct AutumnError {
136    inner: Box<dyn std::error::Error + Send + Sync>,
137    status: StatusCode,
138    details: Option<std::collections::HashMap<String, Vec<String>>>,
139    problem_type: Option<&'static str>,
140}
141
142/// Convenience alias -- the standard return type for Autumn handlers.
143///
144/// Equivalent to `Result<T, AutumnError>`. Use this as the return type
145/// for any handler that might fail.
146///
147/// # Examples
148///
149/// ```rust,no_run
150/// use autumn_web::prelude::*;
151///
152/// #[get("/")]
153/// async fn index() -> AutumnResult<&'static str> {
154///     Ok("hello")
155/// }
156/// ```
157pub type AutumnResult<T> = Result<T, AutumnError>;
158
159impl<E> From<E> for AutumnError
160where
161    E: std::error::Error + Send + Sync + 'static,
162{
163    fn from(err: E) -> Self {
164        Self {
165            inner: Box::new(err),
166            status: StatusCode::INTERNAL_SERVER_ERROR,
167            details: None,
168            problem_type: None,
169        }
170    }
171}
172
173impl AutumnError {
174    /// Override the HTTP status code.
175    ///
176    /// # Examples
177    ///
178    /// ```rust
179    /// use autumn_web::error::AutumnError;
180    /// use http::StatusCode;
181    ///
182    /// let err: AutumnError = std::io::Error::other("forbidden").into();
183    /// let err = err.with_status(StatusCode::FORBIDDEN);
184    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
185    /// ```
186    #[must_use]
187    pub const fn with_status(mut self, status: StatusCode) -> Self {
188        self.status = status;
189        self
190    }
191
192    /// Create a `500 Internal Server Error`.
193    ///
194    /// # Examples
195    ///
196    /// ```rust
197    /// use autumn_web::error::AutumnError;
198    /// use http::StatusCode;
199    ///
200    /// let err = AutumnError::internal_server_error(std::io::Error::other("boom"));
201    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
202    /// ```
203    pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
204        Self {
205            inner: Box::new(err),
206            status: StatusCode::INTERNAL_SERVER_ERROR,
207            details: None,
208            problem_type: None,
209        }
210    }
211
212    /// Create a `404 Not Found` error.
213    ///
214    /// # Examples
215    ///
216    /// ```rust
217    /// use autumn_web::error::AutumnError;
218    /// use http::StatusCode;
219    ///
220    /// let err = AutumnError::not_found(std::io::Error::other("no such user"));
221    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
222    /// ```
223    pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
224        Self {
225            inner: Box::new(err),
226            status: StatusCode::NOT_FOUND,
227            details: None,
228            problem_type: None,
229        }
230    }
231
232    /// Create a `400 Bad Request` error.
233    ///
234    /// # Examples
235    ///
236    /// ```rust
237    /// use autumn_web::error::AutumnError;
238    /// use http::StatusCode;
239    ///
240    /// let err = AutumnError::bad_request(std::io::Error::other("invalid input"));
241    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
242    /// ```
243    pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
244        Self {
245            inner: Box::new(err),
246            status: StatusCode::BAD_REQUEST,
247            details: None,
248            problem_type: None,
249        }
250    }
251
252    /// Create a `422 Unprocessable Entity` error.
253    ///
254    /// Use this for validation failures where the request is syntactically
255    /// valid but semantically incorrect.
256    ///
257    /// # Examples
258    ///
259    /// ```rust
260    /// use autumn_web::error::AutumnError;
261    /// use http::StatusCode;
262    ///
263    /// let err = AutumnError::unprocessable(std::io::Error::other("age must be positive"));
264    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
265    /// ```
266    pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
267        Self {
268            inner: Box::new(err),
269            status: StatusCode::UNPROCESSABLE_ENTITY,
270            details: None,
271            problem_type: None,
272        }
273    }
274
275    /// Create a `503 Service Unavailable` error.
276    ///
277    /// # Examples
278    ///
279    /// ```rust
280    /// use autumn_web::error::AutumnError;
281    /// use http::StatusCode;
282    ///
283    /// let err = AutumnError::service_unavailable(std::io::Error::other("pool exhausted"));
284    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
285    /// ```
286    pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
287        Self {
288            inner: Box::new(err),
289            status: StatusCode::SERVICE_UNAVAILABLE,
290            details: None,
291            problem_type: None,
292        }
293    }
294
295    /// Create a `401 Unauthorized` error.
296    ///
297    /// # Examples
298    ///
299    /// ```rust
300    /// use autumn_web::error::AutumnError;
301    /// use http::StatusCode;
302    ///
303    /// let err = AutumnError::unauthorized(std::io::Error::other("not logged in"));
304    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
305    /// ```
306    pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
307        Self {
308            inner: Box::new(err),
309            status: StatusCode::UNAUTHORIZED,
310            details: None,
311            problem_type: None,
312        }
313    }
314
315    /// Create a `403 Forbidden` error.
316    ///
317    /// # Examples
318    ///
319    /// ```rust
320    /// use autumn_web::error::AutumnError;
321    /// use http::StatusCode;
322    ///
323    /// let err = AutumnError::forbidden(std::io::Error::other("not allowed"));
324    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
325    /// ```
326    pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
327        Self {
328            inner: Box::new(err),
329            status: StatusCode::FORBIDDEN,
330            details: None,
331            problem_type: None,
332        }
333    }
334
335    /// Create a `422 Unprocessable Entity` error with field-level
336    /// validation details.
337    ///
338    /// Use this when a request fails multiple field-specific validation rules
339    /// (e.g., in a form submission). It attaches the `details` parameter, a mapping
340    /// of field names to their respective error messages, so the client can display
341    /// errors next to the relevant inputs.
342    ///
343    /// # Examples
344    ///
345    /// ```rust
346    /// use autumn_web::error::AutumnError;
347    /// use http::StatusCode;
348    /// use std::collections::HashMap;
349    ///
350    /// let mut errors = HashMap::new();
351    /// errors.insert("username".to_string(), vec!["Username is taken".to_string()]);
352    ///
353    /// let err = AutumnError::validation(errors);
354    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
355    /// ```
356    #[must_use]
357    pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
358        Self {
359            inner: Box::new(StringError("Validation failed".into())),
360            status: StatusCode::UNPROCESSABLE_ENTITY,
361            details: Some(details),
362            problem_type: None,
363        }
364    }
365
366    // ── String-message convenience constructors ────────────────
367
368    /// Create a `500 Internal Server Error` from a plain string message.
369    ///
370    /// # Examples
371    ///
372    /// ```rust
373    /// use autumn_web::error::AutumnError;
374    /// use http::StatusCode;
375    ///
376    /// let err = AutumnError::internal_server_error_msg("Database explosion");
377    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
378    /// ```
379    pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
380        Self::internal_server_error(StringError(msg.into()))
381    }
382
383    /// Create a `404 Not Found` error from a plain string message.
384    ///
385    /// # Examples
386    ///
387    /// ```rust
388    /// use autumn_web::error::AutumnError;
389    /// use http::StatusCode;
390    ///
391    /// let err = AutumnError::not_found_msg("No such user");
392    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
393    /// assert_eq!(err.to_string(), "No such user");
394    /// ```
395    pub fn not_found_msg(msg: impl Into<String>) -> Self {
396        Self::not_found(StringError(msg.into()))
397    }
398
399    /// Create a `400 Bad Request` error from a plain string message.
400    ///
401    /// # Examples
402    ///
403    /// ```rust
404    /// use autumn_web::error::AutumnError;
405    /// use http::StatusCode;
406    ///
407    /// let err = AutumnError::bad_request_msg("Invalid input parameter");
408    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
409    /// ```
410    pub fn bad_request_msg(msg: impl Into<String>) -> Self {
411        Self::bad_request(StringError(msg.into()))
412    }
413
414    /// Create a `422 Unprocessable Entity` error from a plain string message.
415    ///
416    /// # Examples
417    ///
418    /// ```rust
419    /// use autumn_web::error::AutumnError;
420    /// use http::StatusCode;
421    ///
422    /// let err = AutumnError::unprocessable_msg("Title is required");
423    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
424    /// ```
425    pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
426        Self::unprocessable(StringError(msg.into()))
427    }
428
429    /// Create a `401 Unauthorized` error from a plain string message.
430    ///
431    /// # Examples
432    ///
433    /// ```rust
434    /// use autumn_web::error::AutumnError;
435    /// use http::StatusCode;
436    ///
437    /// let err = AutumnError::unauthorized_msg("Please log in to continue");
438    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
439    /// ```
440    pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
441        Self::unauthorized(StringError(msg.into()))
442    }
443
444    /// Create a `403 Forbidden` error from a plain string message.
445    ///
446    /// # Examples
447    ///
448    /// ```rust
449    /// use autumn_web::error::AutumnError;
450    /// use http::StatusCode;
451    ///
452    /// let err = AutumnError::forbidden_msg("You lack admin privileges");
453    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
454    /// ```
455    pub fn forbidden_msg(msg: impl Into<String>) -> Self {
456        Self::forbidden(StringError(msg.into()))
457    }
458
459    /// Create a `503 Service Unavailable` error from a plain string message.
460    ///
461    /// # Examples
462    ///
463    /// ```rust
464    /// use autumn_web::error::AutumnError;
465    /// use http::StatusCode;
466    ///
467    /// let err = AutumnError::service_unavailable_msg("Database connection pool exhausted");
468    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
469    /// ```
470    pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
471        Self::service_unavailable(StringError(msg.into()))
472    }
473
474    /// Create a `409 Conflict` error.
475    ///
476    /// Use this for optimistic-lock conflicts surfaced by repository `update`
477    /// calls when the client's expected version is stale.
478    ///
479    /// # Examples
480    ///
481    /// ```rust
482    /// use autumn_web::error::AutumnError;
483    /// use http::StatusCode;
484    ///
485    /// let err = AutumnError::conflict(std::io::Error::other("stale version"));
486    /// assert_eq!(err.status(), StatusCode::CONFLICT);
487    /// ```
488    pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
489        Self {
490            inner: Box::new(err),
491            status: StatusCode::CONFLICT,
492            details: None,
493            problem_type: Some("https://autumn.dev/problems/conflict"),
494        }
495    }
496
497    /// Create a `409 Conflict` error from a plain string message.
498    ///
499    /// # Examples
500    ///
501    /// ```rust
502    /// use autumn_web::error::AutumnError;
503    /// use http::StatusCode;
504    ///
505    /// let err = AutumnError::conflict_msg("Concurrent edit: please reload and retry");
506    /// assert_eq!(err.status(), StatusCode::CONFLICT);
507    /// ```
508    pub fn conflict_msg(msg: impl Into<String>) -> Self {
509        Self::conflict(StringError(msg.into()))
510    }
511
512    /// Returns the HTTP status code associated with this error.
513    ///
514    /// # Examples
515    ///
516    /// ```rust
517    /// use autumn_web::error::AutumnError;
518    /// use http::StatusCode;
519    ///
520    /// let err: AutumnError = std::io::Error::other("boom").into();
521    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
522    /// ```
523    #[must_use]
524    pub const fn status(&self) -> StatusCode {
525        self.status
526    }
527
528    /// Return the wrapped error's source chain as displayable messages.
529    ///
530    /// The top-level [`AutumnError`] display already prints the wrapped error
531    /// message, so this list starts at that wrapped error's first source.
532    #[must_use]
533    pub fn source_chain(&self) -> Vec<String> {
534        let mut chain = Vec::new();
535        let mut source = self.inner.source();
536        while let Some(error) = source {
537            chain.push(error.to_string());
538            source = error.source();
539        }
540        chain
541    }
542}
543
544impl std::fmt::Display for AutumnError {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        write!(f, "{}", self.inner)
547    }
548}
549
550impl std::fmt::Debug for AutumnError {
551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        f.debug_struct("AutumnError")
553            .field("status", &self.status)
554            .field("inner", &self.inner)
555            .field("details", &self.details)
556            .field("problem_type", &self.problem_type)
557            .finish()
558    }
559}
560
561impl ProblemDetails {
562    /// Build a Problem Details payload from framework error metadata.
563    #[must_use]
564    pub fn new(
565        status: StatusCode,
566        detail: impl Into<String>,
567        details: Option<&std::collections::HashMap<String, Vec<String>>>,
568    ) -> Self {
569        problem_details(status, detail.into(), details, None, None, None, true)
570    }
571}
572
573/// Build the canonical Problem Details payload.
574#[must_use]
575pub(crate) fn problem_details(
576    status: StatusCode,
577    detail: String,
578    details: Option<&std::collections::HashMap<String, Vec<String>>>,
579    explicit_type: Option<&'static str>,
580    request_id: Option<String>,
581    instance: Option<String>,
582    expose_internal_detail: bool,
583) -> ProblemDetails {
584    let has_validation_errors = details.is_some_and(|map| !map.is_empty());
585    let safe_detail = if status.is_server_error() && !expose_internal_detail {
586        server_error_detail(status)
587    } else {
588        detail
589    };
590
591    ProblemDetails {
592        type_uri: explicit_type
593            .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
594            .to_owned(),
595        title: problem_title_for(status, has_validation_errors).to_owned(),
596        status: status.as_u16(),
597        detail: safe_detail,
598        instance,
599        code: problem_code_for(status, has_validation_errors).to_owned(),
600        request_id,
601        errors: validation_errors(details),
602    }
603}
604
605/// Serialize a Problem Details payload for middleware that cannot return
606/// `axum::Json` directly because its response body type is generic.
607#[must_use]
608pub(crate) fn problem_details_json_string(
609    status: StatusCode,
610    detail: impl Into<String>,
611    details: Option<&std::collections::HashMap<String, Vec<String>>>,
612    explicit_type: Option<&'static str>,
613    request_id: Option<String>,
614    instance: Option<String>,
615    expose_internal_detail: bool,
616) -> String {
617    let problem = problem_details(
618        status,
619        detail.into(),
620        details,
621        explicit_type,
622        request_id,
623        instance,
624        expose_internal_detail,
625    );
626    problem_details_to_json_string(&problem)
627}
628
629/// Serialize an already-built Problem Details payload.
630#[must_use]
631pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
632    serde_json::to_string(&problem).unwrap_or_else(|_| {
633        r#"{"type":"https://autumn.dev/problems/internal-server-error","title":"Internal Server Error","status":500,"detail":"Internal server error","instance":null,"code":"autumn.internal_server_error","request_id":null,"errors":[]}"#.to_owned()
634    })
635}
636
637fn validation_errors(
638    details: Option<&std::collections::HashMap<String, Vec<String>>>,
639) -> Vec<ProblemFieldError> {
640    let mut errors: Vec<_> = details
641        .into_iter()
642        .flat_map(std::collections::HashMap::iter)
643        .map(|(field, messages)| ProblemFieldError {
644            field: field.clone(),
645            messages: messages.clone(),
646        })
647        .collect();
648    errors.sort_by(|left, right| left.field.cmp(&right.field));
649    errors
650}
651
652const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
653    if has_validation_errors {
654        return "https://autumn.dev/problems/validation-failed";
655    }
656
657    match status {
658        StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
659        StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
660        StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
661        StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
662        StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
663        StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
664        StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
665        StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
666        StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
667        StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
668        _ => "about:blank",
669    }
670}
671
672fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
673    if has_validation_errors {
674        return "Validation Failed";
675    }
676
677    match status {
678        StatusCode::BAD_REQUEST => "Bad Request",
679        StatusCode::UNAUTHORIZED => "Unauthorized",
680        StatusCode::FORBIDDEN => "Forbidden",
681        StatusCode::NOT_FOUND => "Not Found",
682        StatusCode::CONFLICT => "Conflict",
683        StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
684        StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
685        StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
686        StatusCode::NOT_IMPLEMENTED => "Not Implemented",
687        StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
688        _ => status.canonical_reason().unwrap_or("Error"),
689    }
690}
691
692fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
693    if has_validation_errors {
694        return "autumn.validation_failed";
695    }
696
697    match status {
698        StatusCode::BAD_REQUEST => "autumn.bad_request",
699        StatusCode::UNAUTHORIZED => "autumn.unauthorized",
700        StatusCode::FORBIDDEN => "autumn.forbidden",
701        StatusCode::NOT_FOUND => "autumn.not_found",
702        StatusCode::CONFLICT => "autumn.conflict",
703        StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
704        StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
705        StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
706        StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
707        StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
708        _ if status.is_client_error() => "autumn.client_error",
709        _ if status.is_server_error() => "autumn.server_error",
710        _ => "autumn.error",
711    }
712}
713
714fn server_error_detail(status: StatusCode) -> String {
715    match status {
716        StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
717        StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
718        _ => "Internal server error".to_owned(),
719    }
720}
721
722impl IntoResponse for AutumnError {
723    fn into_response(self) -> Response {
724        let status = self.status;
725        let message = self.inner.to_string();
726        let details = self.details.clone();
727        let problem_type = self.problem_type;
728
729        // Stash error metadata for exception filters to inspect without
730        // parsing the response body.
731        let error_info = crate::middleware::AutumnErrorInfo {
732            status,
733            message: message.clone(),
734            details: details.clone(),
735            problem_type,
736        };
737
738        let body = problem_details(
739            status,
740            message,
741            details.as_ref(),
742            problem_type,
743            None,
744            None,
745            true,
746        );
747        let mut response = (status, axum::Json(body)).into_response();
748        response.headers_mut().insert(
749            header::CONTENT_TYPE,
750            HeaderValue::from_static("application/problem+json"),
751        );
752        if status == StatusCode::CONFLICT {
753            response.headers_mut().insert(
754                "HX-Trigger",
755                HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
756            );
757        }
758        response.extensions_mut().insert(error_info);
759        response
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use axum::http::StatusCode;
767
768    #[derive(Debug)]
769    struct TestError(String);
770
771    impl std::fmt::Display for TestError {
772        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773            write!(f, "{}", self.0)
774        }
775    }
776
777    impl std::error::Error for TestError {}
778
779    #[derive(Debug)]
780    struct WrappedError {
781        message: String,
782        source: TestError,
783    }
784
785    impl std::fmt::Display for WrappedError {
786        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787            write!(f, "{}", self.message)
788        }
789    }
790
791    impl std::error::Error for WrappedError {
792        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
793            Some(&self.source)
794        }
795    }
796
797    #[test]
798    fn blanket_from_defaults_to_500() {
799        let err: AutumnError = TestError("boom".into()).into();
800        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
801    }
802
803    #[test]
804    fn internal_server_error_is_500() {
805        let err = AutumnError::internal_server_error(TestError("boom".into()));
806        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
807    }
808
809    #[test]
810    fn test_not_found_error() {
811        let err = AutumnError::not_found(std::io::Error::other("no such user"));
812        assert_eq!(err.status(), StatusCode::NOT_FOUND);
813    }
814
815    #[test]
816    fn not_found_is_404() {
817        let err = AutumnError::not_found(TestError("missing".into()));
818        assert_eq!(err.status(), StatusCode::NOT_FOUND);
819    }
820
821    #[test]
822    fn bad_request_is_400() {
823        let err = AutumnError::bad_request(TestError("invalid input".into()));
824        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
825    }
826
827    #[test]
828    fn unprocessable_is_422() {
829        let err = AutumnError::unprocessable(TestError("bad entity".into()));
830        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
831    }
832
833    #[test]
834    fn unauthorized_is_401() {
835        let err = AutumnError::unauthorized(TestError("unauthorized".into()));
836        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
837    }
838
839    #[test]
840    fn forbidden_is_403() {
841        let err = AutumnError::forbidden(TestError("forbidden".into()));
842        assert_eq!(err.status(), StatusCode::FORBIDDEN);
843    }
844
845    #[test]
846    fn validation_is_422() {
847        let mut details = std::collections::HashMap::new();
848        details.insert("field".to_string(), vec!["error".to_string()]);
849        let err = AutumnError::validation(details);
850        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
851    }
852
853    #[test]
854    fn service_unavailable_is_503() {
855        let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
856        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
857    }
858
859    #[test]
860    fn internal_server_error_msg_is_500() {
861        let err = AutumnError::internal_server_error_msg("db failure");
862        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
863        assert_eq!(err.to_string(), "db failure");
864    }
865
866    #[test]
867    fn not_found_msg_is_404() {
868        let err = AutumnError::not_found_msg("no such user");
869        assert_eq!(err.status(), StatusCode::NOT_FOUND);
870        assert_eq!(err.to_string(), "no such user");
871    }
872
873    #[test]
874    fn bad_request_msg_is_400() {
875        let err = AutumnError::bad_request_msg("invalid input");
876        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
877    }
878
879    #[test]
880    fn unprocessable_msg_is_422() {
881        let err = AutumnError::unprocessable_msg("title required");
882        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
883    }
884
885    #[test]
886    fn unauthorized_msg_is_401() {
887        let err = AutumnError::unauthorized_msg("login required");
888        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
889    }
890
891    #[test]
892    fn forbidden_msg_is_403() {
893        let err = AutumnError::forbidden_msg("no access");
894        assert_eq!(err.status(), StatusCode::FORBIDDEN);
895    }
896
897    #[test]
898    fn service_unavailable_msg_is_503() {
899        let err = AutumnError::service_unavailable_msg("db down");
900        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
901        assert_eq!(err.to_string(), "db down");
902    }
903
904    #[test]
905    fn with_status_overrides() {
906        let err: AutumnError = TestError("forbidden".into()).into();
907        let err = err.with_status(StatusCode::FORBIDDEN);
908        assert_eq!(err.status(), StatusCode::FORBIDDEN);
909    }
910
911    #[test]
912    fn display_uses_inner_message() {
913        let err: AutumnError = TestError("something broke".into()).into();
914        assert_eq!(err.to_string(), "something broke");
915    }
916
917    #[test]
918    fn source_chain_lists_inner_sources() {
919        let err = AutumnError::internal_server_error(WrappedError {
920            message: "failed to backfill".to_string(),
921            source: TestError("database connection dropped".to_string()),
922        });
923
924        assert_eq!(
925            err.source_chain(),
926            vec!["database connection dropped".to_string()]
927        );
928    }
929
930    #[test]
931    fn into_response_has_correct_status() {
932        let err = AutumnError::not_found(TestError("not found".into()));
933        let response = err.into_response();
934        assert_eq!(response.status(), StatusCode::NOT_FOUND);
935    }
936
937    #[tokio::test]
938    async fn into_response_has_json_body() -> Result<(), axum::Error> {
939        let err = AutumnError::not_found(TestError("not found".into()));
940        let response = err.into_response();
941
942        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
943        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
944
945        assert_eq!(json["status"], 404);
946        assert_eq!(json["detail"], "not found");
947        assert_eq!(json["code"], "autumn.not_found");
948        Ok(())
949    }
950
951    #[test]
952    fn debug_shows_status_and_inner() {
953        let err = AutumnError::bad_request(TestError("oops".into()));
954        let debug = format!("{err:?}");
955        assert!(debug.contains("AutumnError"));
956        assert!(debug.contains("400"));
957    }
958
959    #[tokio::test]
960    async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
961        let err = AutumnError::unprocessable_msg("title required");
962        let response = err.into_response();
963
964        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
965        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
966        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
967        assert_eq!(json["status"], 422);
968        assert_eq!(json["detail"], "title required");
969        assert_eq!(json["code"], "autumn.unprocessable_entity");
970        Ok(())
971    }
972
973    #[tokio::test]
974    async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
975        let err = AutumnError::service_unavailable_msg("db down");
976        let response = err.into_response();
977
978        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
979        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
980        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
981        assert_eq!(json["status"], 503);
982        assert_eq!(json["detail"], "db down");
983        assert_eq!(json["code"], "autumn.service_unavailable");
984        Ok(())
985    }
986
987    #[test]
988    fn conflict_is_409() {
989        let err = AutumnError::conflict(TestError("stale version".into()));
990        assert_eq!(err.status(), StatusCode::CONFLICT);
991    }
992
993    #[test]
994    fn conflict_msg_is_409() {
995        let err = AutumnError::conflict_msg("please reload and retry");
996        assert_eq!(err.status(), StatusCode::CONFLICT);
997        assert_eq!(err.to_string(), "please reload and retry");
998    }
999
1000    #[tokio::test]
1001    async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1002        let err = AutumnError::conflict_msg("version mismatch");
1003        let response = err.into_response();
1004
1005        assert_eq!(response.status(), StatusCode::CONFLICT);
1006        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1007        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1008        assert_eq!(json["status"], 409);
1009        assert_eq!(json["detail"], "version mismatch");
1010        assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1011        assert_eq!(json["title"], "Conflict");
1012        Ok(())
1013    }
1014
1015    #[tokio::test]
1016    async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1017        let err = AutumnError::conflict_msg("version mismatch");
1018        let response = err.into_response();
1019
1020        assert_eq!(response.status(), StatusCode::CONFLICT);
1021        let hx_trigger = response
1022            .headers()
1023            .get("HX-Trigger")
1024            .expect("HX-Trigger header present");
1025        assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1026        Ok(())
1027    }
1028}