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    cache_idempotency_response: bool,
141    /// Backtrace captured at error creation time in debug builds.
142    /// Transferred to `AutumnErrorInfo` for the dev overlay.
143    #[cfg(debug_assertions)]
144    pub(crate) backtrace_string: Option<String>,
145}
146
147/// Convenience alias -- the standard return type for Autumn handlers.
148///
149/// Equivalent to `Result<T, AutumnError>`. Use this as the return type
150/// for any handler that might fail.
151///
152/// # Examples
153///
154/// ```rust,no_run
155/// use autumn_web::prelude::*;
156///
157/// #[get("/")]
158/// async fn index() -> AutumnResult<&'static str> {
159///     Ok("hello")
160/// }
161/// ```
162pub type AutumnResult<T> = Result<T, AutumnError>;
163
164impl<E> From<E> for AutumnError
165where
166    E: std::error::Error + Send + Sync + 'static,
167{
168    fn from(err: E) -> Self {
169        let mut status = StatusCode::INTERNAL_SERVER_ERROR;
170        let any_err: &dyn std::any::Any = &err;
171
172        if std::any::type_name::<E>().contains("CircuitBreakerError")
173            && err.to_string() == "circuit breaker is open"
174        {
175            status = StatusCode::SERVICE_UNAVAILABLE;
176        }
177
178        #[cfg(feature = "http-client")]
179        {
180            if matches!(
181                any_err.downcast_ref::<crate::http_client::ClientError>(),
182                Some(crate::http_client::ClientError::CircuitBreakerOpen)
183            ) {
184                status = StatusCode::SERVICE_UNAVAILABLE;
185            }
186        }
187
188        if matches!(
189            any_err.downcast_ref::<crate::lock::LockError>(),
190            Some(
191                crate::lock::LockError::PoolUnavailable(_) | crate::lock::LockError::Timeout { .. }
192            )
193        ) {
194            status = StatusCode::SERVICE_UNAVAILABLE;
195        }
196
197        #[cfg(feature = "mail")]
198        {
199            if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
200                any_err.downcast_ref::<crate::mail::MailError>()
201                && msg.contains("circuit breaker is open")
202            {
203                status = StatusCode::SERVICE_UNAVAILABLE;
204            }
205        }
206
207        // A per-tenant memory quota breach is a soft, retryable resource limit,
208        // so it maps to 503 Service Unavailable rather than a 500.
209        if any_err
210            .downcast_ref::<crate::tenant_cell::QuotaExceeded>()
211            .is_some()
212        {
213            status = StatusCode::SERVICE_UNAVAILABLE;
214        }
215
216        Self {
217            inner: Box::new(err),
218            status,
219            details: None,
220            problem_type: None,
221            cache_idempotency_response: false,
222            #[cfg(debug_assertions)]
223            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
224        }
225    }
226}
227
228impl AutumnError {
229    /// Override the HTTP status code.
230    ///
231    /// # Examples
232    ///
233    /// ```rust
234    /// use autumn_web::error::AutumnError;
235    /// use http::StatusCode;
236    ///
237    /// let err: AutumnError = std::io::Error::other("forbidden").into();
238    /// let err = err.with_status(StatusCode::FORBIDDEN);
239    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
240    /// ```
241    #[must_use]
242    pub const fn with_status(mut self, status: StatusCode) -> Self {
243        self.status = status;
244        self
245    }
246
247    /// Create a `500 Internal Server Error`.
248    ///
249    /// # Examples
250    ///
251    /// ```rust
252    /// use autumn_web::error::AutumnError;
253    /// use http::StatusCode;
254    ///
255    /// let err = AutumnError::internal_server_error(std::io::Error::other("boom"));
256    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
257    /// ```
258    pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
259        Self {
260            inner: Box::new(err),
261            status: StatusCode::INTERNAL_SERVER_ERROR,
262            details: None,
263            problem_type: None,
264            cache_idempotency_response: false,
265            #[cfg(debug_assertions)]
266            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
267        }
268    }
269
270    /// Create a `404 Not Found` error.
271    ///
272    /// # Examples
273    ///
274    /// ```rust
275    /// use autumn_web::error::AutumnError;
276    /// use http::StatusCode;
277    ///
278    /// let err = AutumnError::not_found(std::io::Error::other("no such user"));
279    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
280    /// ```
281    pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
282        Self {
283            inner: Box::new(err),
284            status: StatusCode::NOT_FOUND,
285            details: None,
286            problem_type: None,
287            cache_idempotency_response: false,
288            #[cfg(debug_assertions)]
289            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
290        }
291    }
292
293    /// Create a `400 Bad Request` error.
294    ///
295    /// # Examples
296    ///
297    /// ```rust
298    /// use autumn_web::error::AutumnError;
299    /// use http::StatusCode;
300    ///
301    /// let err = AutumnError::bad_request(std::io::Error::other("invalid input"));
302    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
303    /// ```
304    pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
305        Self {
306            inner: Box::new(err),
307            status: StatusCode::BAD_REQUEST,
308            details: None,
309            problem_type: None,
310            cache_idempotency_response: false,
311            #[cfg(debug_assertions)]
312            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
313        }
314    }
315
316    /// Create a `422 Unprocessable Entity` error.
317    ///
318    /// Use this for validation failures where the request is syntactically
319    /// valid but semantically incorrect.
320    ///
321    /// # Examples
322    ///
323    /// ```rust
324    /// use autumn_web::error::AutumnError;
325    /// use http::StatusCode;
326    ///
327    /// let err = AutumnError::unprocessable(std::io::Error::other("age must be positive"));
328    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
329    /// ```
330    pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
331        Self {
332            inner: Box::new(err),
333            status: StatusCode::UNPROCESSABLE_ENTITY,
334            details: None,
335            problem_type: None,
336            cache_idempotency_response: false,
337            #[cfg(debug_assertions)]
338            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
339        }
340    }
341
342    /// Create a `503 Service Unavailable` error.
343    ///
344    /// # Examples
345    ///
346    /// ```rust
347    /// use autumn_web::error::AutumnError;
348    /// use http::StatusCode;
349    ///
350    /// let err = AutumnError::service_unavailable(std::io::Error::other("pool exhausted"));
351    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
352    /// ```
353    pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
354        Self {
355            inner: Box::new(err),
356            status: StatusCode::SERVICE_UNAVAILABLE,
357            details: None,
358            problem_type: None,
359            cache_idempotency_response: false,
360            #[cfg(debug_assertions)]
361            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
362        }
363    }
364
365    /// Create a `401 Unauthorized` error.
366    ///
367    /// # Examples
368    ///
369    /// ```rust
370    /// use autumn_web::error::AutumnError;
371    /// use http::StatusCode;
372    ///
373    /// let err = AutumnError::unauthorized(std::io::Error::other("not logged in"));
374    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
375    /// ```
376    pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
377        Self {
378            inner: Box::new(err),
379            status: StatusCode::UNAUTHORIZED,
380            details: None,
381            problem_type: None,
382            cache_idempotency_response: false,
383            #[cfg(debug_assertions)]
384            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
385        }
386    }
387
388    /// Create a `403 Forbidden` error.
389    ///
390    /// # Examples
391    ///
392    /// ```rust
393    /// use autumn_web::error::AutumnError;
394    /// use http::StatusCode;
395    ///
396    /// let err = AutumnError::forbidden(std::io::Error::other("not allowed"));
397    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
398    /// ```
399    pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
400        Self {
401            inner: Box::new(err),
402            status: StatusCode::FORBIDDEN,
403            details: None,
404            problem_type: None,
405            cache_idempotency_response: false,
406            #[cfg(debug_assertions)]
407            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
408        }
409    }
410
411    /// Create a `422 Unprocessable Entity` error with field-level
412    /// validation details.
413    ///
414    /// Use this when a request fails multiple field-specific validation rules
415    /// (e.g., in a form submission). It attaches the `details` parameter, a mapping
416    /// of field names to their respective error messages, so the client can display
417    /// errors next to the relevant inputs.
418    ///
419    /// # Examples
420    ///
421    /// ```rust
422    /// use autumn_web::error::AutumnError;
423    /// use http::StatusCode;
424    /// use std::collections::HashMap;
425    ///
426    /// let mut errors = HashMap::new();
427    /// errors.insert("username".to_string(), vec!["Username is taken".to_string()]);
428    ///
429    /// let err = AutumnError::validation(errors);
430    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
431    /// ```
432    #[must_use]
433    pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
434        Self {
435            inner: Box::new(StringError("Validation failed".into())),
436            status: StatusCode::UNPROCESSABLE_ENTITY,
437            details: Some(details),
438            problem_type: None,
439            cache_idempotency_response: false,
440            #[cfg(debug_assertions)]
441            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
442        }
443    }
444
445    // ── String-message convenience constructors ────────────────
446
447    /// Create a `500 Internal Server Error` from a plain string message.
448    ///
449    /// # Examples
450    ///
451    /// ```rust
452    /// use autumn_web::error::AutumnError;
453    /// use http::StatusCode;
454    ///
455    /// let err = AutumnError::internal_server_error_msg("Database explosion");
456    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
457    /// ```
458    pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
459        Self::internal_server_error(StringError(msg.into()))
460    }
461
462    /// Create a `404 Not Found` error from a plain string message.
463    ///
464    /// # Examples
465    ///
466    /// ```rust
467    /// use autumn_web::error::AutumnError;
468    /// use http::StatusCode;
469    ///
470    /// let err = AutumnError::not_found_msg("No such user");
471    /// assert_eq!(err.status(), StatusCode::NOT_FOUND);
472    /// assert_eq!(err.to_string(), "No such user");
473    /// ```
474    pub fn not_found_msg(msg: impl Into<String>) -> Self {
475        Self::not_found(StringError(msg.into()))
476    }
477
478    /// Create a `400 Bad Request` error from a plain string message.
479    ///
480    /// # Examples
481    ///
482    /// ```rust
483    /// use autumn_web::error::AutumnError;
484    /// use http::StatusCode;
485    ///
486    /// let err = AutumnError::bad_request_msg("Invalid input parameter");
487    /// assert_eq!(err.status(), StatusCode::BAD_REQUEST);
488    /// ```
489    pub fn bad_request_msg(msg: impl Into<String>) -> Self {
490        Self::bad_request(StringError(msg.into()))
491    }
492
493    /// Create a `422 Unprocessable Entity` error from a plain string message.
494    ///
495    /// # Examples
496    ///
497    /// ```rust
498    /// use autumn_web::error::AutumnError;
499    /// use http::StatusCode;
500    ///
501    /// let err = AutumnError::unprocessable_msg("Title is required");
502    /// assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
503    /// ```
504    pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
505        Self::unprocessable(StringError(msg.into()))
506    }
507
508    /// Create a `401 Unauthorized` error from a plain string message.
509    ///
510    /// # Examples
511    ///
512    /// ```rust
513    /// use autumn_web::error::AutumnError;
514    /// use http::StatusCode;
515    ///
516    /// let err = AutumnError::unauthorized_msg("Please log in to continue");
517    /// assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
518    /// ```
519    pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
520        Self::unauthorized(StringError(msg.into()))
521    }
522
523    /// Create a `403 Forbidden` error from a plain string message.
524    ///
525    /// # Examples
526    ///
527    /// ```rust
528    /// use autumn_web::error::AutumnError;
529    /// use http::StatusCode;
530    ///
531    /// let err = AutumnError::forbidden_msg("You lack admin privileges");
532    /// assert_eq!(err.status(), StatusCode::FORBIDDEN);
533    /// ```
534    pub fn forbidden_msg(msg: impl Into<String>) -> Self {
535        Self::forbidden(StringError(msg.into()))
536    }
537
538    /// Create a `503 Service Unavailable` error from a plain string message.
539    ///
540    /// # Examples
541    ///
542    /// ```rust
543    /// use autumn_web::error::AutumnError;
544    /// use http::StatusCode;
545    ///
546    /// let err = AutumnError::service_unavailable_msg("Database connection pool exhausted");
547    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
548    /// ```
549    pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
550        Self::service_unavailable(StringError(msg.into()))
551    }
552
553    /// Create a `409 Conflict` error.
554    ///
555    /// Use this for optimistic-lock conflicts surfaced by repository `update`
556    /// calls when the client's expected version is stale.
557    ///
558    /// # Examples
559    ///
560    /// ```rust
561    /// use autumn_web::error::AutumnError;
562    /// use http::StatusCode;
563    ///
564    /// let err = AutumnError::conflict(std::io::Error::other("stale version"));
565    /// assert_eq!(err.status(), StatusCode::CONFLICT);
566    /// ```
567    pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
568        Self {
569            inner: Box::new(err),
570            status: StatusCode::CONFLICT,
571            details: None,
572            problem_type: Some("https://autumn.dev/problems/conflict"),
573            cache_idempotency_response: false,
574            #[cfg(debug_assertions)]
575            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
576        }
577    }
578
579    /// Create a `409 Conflict` error from a plain string message.
580    ///
581    /// # Examples
582    ///
583    /// ```rust
584    /// use autumn_web::error::AutumnError;
585    /// use http::StatusCode;
586    ///
587    /// let err = AutumnError::conflict_msg("Concurrent edit: please reload and retry");
588    /// assert_eq!(err.status(), StatusCode::CONFLICT);
589    /// ```
590    pub fn conflict_msg(msg: impl Into<String>) -> Self {
591        Self::conflict(StringError(msg.into()))
592    }
593
594    /// Create a `410 Gone` error.
595    ///
596    /// # Examples
597    ///
598    /// ```rust
599    /// use autumn_web::error::AutumnError;
600    /// use http::StatusCode;
601    ///
602    /// let err = AutumnError::gone(std::io::Error::other("sunsetted"));
603    /// assert_eq!(err.status(), StatusCode::GONE);
604    /// ```
605    pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
606        Self {
607            inner: Box::new(err),
608            status: StatusCode::GONE,
609            details: None,
610            problem_type: Some("https://autumn.dev/problems/gone"),
611            cache_idempotency_response: false,
612            #[cfg(debug_assertions)]
613            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
614        }
615    }
616
617    /// Create a `410 Gone` error from a plain string message.
618    ///
619    /// # Examples
620    ///
621    /// ```rust
622    /// use autumn_web::error::AutumnError;
623    /// use http::StatusCode;
624    ///
625    /// let err = AutumnError::gone_msg("API version has been sunsetted");
626    /// assert_eq!(err.status(), StatusCode::GONE);
627    /// ```
628    pub fn gone_msg(msg: impl Into<String>) -> Self {
629        Self::gone(StringError(msg.into()))
630    }
631
632    /// Create a `503 Service Unavailable` error indicating that a database
633    /// query was cancelled due to a statement timeout (Postgres `57014`).
634    ///
635    /// The problem details payload carries `"autumn.query_timeout"` as the
636    /// machine-readable code, which allows clients to distinguish a transient
637    /// timeout from other 503 conditions and apply appropriate retry logic.
638    ///
639    /// # Examples
640    ///
641    /// ```rust
642    /// use autumn_web::error::AutumnError;
643    /// use http::StatusCode;
644    ///
645    /// let err = AutumnError::query_timeout("query exceeded statement_timeout");
646    /// assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
647    /// ```
648    pub fn query_timeout(msg: impl Into<String>) -> Self {
649        Self {
650            inner: Box::new(StringError(msg.into())),
651            status: StatusCode::SERVICE_UNAVAILABLE,
652            details: None,
653            problem_type: Some("https://autumn.dev/problems/query-timeout"),
654            cache_idempotency_response: false,
655            #[cfg(debug_assertions)]
656            backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
657        }
658    }
659
660    /// Returns the HTTP status code associated with this error.
661    ///
662    /// # Examples
663    ///
664    /// ```rust
665    /// use autumn_web::error::AutumnError;
666    /// use http::StatusCode;
667    ///
668    /// let err: AutumnError = std::io::Error::other("boom").into();
669    /// assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
670    /// ```
671    #[must_use]
672    pub const fn status(&self) -> StatusCode {
673        self.status
674    }
675
676    #[doc(hidden)]
677    #[must_use]
678    pub(crate) const fn cache_idempotency_response(mut self) -> Self {
679        self.cache_idempotency_response = true;
680        self
681    }
682
683    /// Return the wrapped error's source chain as displayable messages.
684    ///
685    /// The top-level [`AutumnError`] display already prints the wrapped error
686    /// message, so this list starts at that wrapped error's first source.
687    #[must_use]
688    pub fn source_chain(&self) -> Vec<String> {
689        let mut chain = Vec::new();
690        let mut source = self.inner.source();
691        while let Some(error) = source {
692            chain.push(error.to_string());
693            source = error.source();
694        }
695        chain
696    }
697
698    /// Try to downcast the inner error to a specific type.
699    #[must_use]
700    pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
701        let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
702        err.downcast_ref::<T>()
703    }
704
705    /// Try to downcast the inner error, or any error in its `source()` chain,
706    /// to a specific type.
707    ///
708    /// Unlike [`downcast_ref`](Self::downcast_ref), which only inspects the
709    /// top-level wrapped error, this walks the full chain — useful when a
710    /// custom error type wraps a lower-level error (e.g. via
711    /// `#[source]`/`#[from]`) without itself being that type.
712    #[must_use]
713    pub fn downcast_chain_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
714        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self.inner.as_ref());
715        while let Some(err) = current {
716            if let Some(t) = err.downcast_ref::<T>() {
717                return Some(t);
718            }
719            current = err.source();
720        }
721        None
722    }
723}
724
725/// Checks whether `err`'s inner error is a Postgres unique-constraint
726/// violation (SQLSTATE `23505`) matching `mapping` (issue #1032).
727///
728/// If `err` is a unique-violation whose constraint name matches one of
729/// `mapping`'s entries, returns the offending field name and the message to
730/// surface inline. `mapping` pairs each unique index/constraint name (e.g.
731/// `idx_users_email_unique` — see `autumn generate`'s
732/// `schema_edit::unique_index_sql`) with the form field name and message to
733/// show when that constraint is violated.
734///
735/// Returns `None` for any other error, or for an unrecognized constraint
736/// name — both cases the caller should propagate normally (`?`/[`From`]),
737/// which falls through to the blanket `500` mapping. This is the single
738/// shared place this classification happens; generated `create`/`update`
739/// handlers call it instead of hand-rolling a `DatabaseErrorKind` match
740/// per scaffold.
741///
742/// Works whether `err` wraps a raw `diesel::result::Error` directly (a bare
743/// `.execute(...).await?`) or one already converted by a generated
744/// repository method (`repo.save(...).await?`) — both route the original
745/// diesel error through the `?` operator's blanket [`From`] impl, so
746/// [`AutumnError::downcast_ref`] recovers it either way.
747///
748/// # Examples
749///
750/// ```rust
751/// use autumn_web::error::{AutumnError, unique_violation_field};
752///
753/// let err = AutumnError::internal_server_error_msg("not a db error");
754/// assert_eq!(unique_violation_field(&err, &[("idx_users_email_unique", "email", "taken")]), None);
755/// ```
756#[cfg(feature = "db")]
757#[must_use]
758pub fn unique_violation_field<'a>(
759    err: &AutumnError,
760    mapping: &'a [(&str, &str, &str)],
761) -> Option<(&'a str, &'a str)> {
762    let diesel::result::Error::DatabaseError(
763        diesel::result::DatabaseErrorKind::UniqueViolation,
764        info,
765    ) = err.downcast_ref::<diesel::result::Error>()?
766    else {
767        return None;
768    };
769    let constraint = info.constraint_name()?;
770    mapping
771        .iter()
772        .find(|(c, _, _)| *c == constraint)
773        .map(|(_, field, message)| (*field, *message))
774}
775
776impl std::fmt::Display for AutumnError {
777    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778        write!(f, "{}", self.inner)
779    }
780}
781
782impl std::fmt::Debug for AutumnError {
783    #[allow(clippy::missing_fields_in_debug)]
784    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785        f.debug_struct("AutumnError")
786            .field("status", &self.status)
787            .field("inner", &self.inner)
788            .field("details", &self.details)
789            .field("problem_type", &self.problem_type)
790            .field(
791                "cache_idempotency_response",
792                &self.cache_idempotency_response,
793            )
794            .finish_non_exhaustive()
795    }
796}
797
798impl ProblemDetails {
799    /// Build a Problem Details payload from framework error metadata.
800    #[must_use]
801    pub fn new(
802        status: StatusCode,
803        detail: impl Into<String>,
804        details: Option<&std::collections::HashMap<String, Vec<String>>>,
805    ) -> Self {
806        problem_details(status, detail.into(), details, None, None, None, true)
807    }
808}
809
810/// Build the canonical Problem Details payload.
811#[must_use]
812pub(crate) fn problem_details(
813    status: StatusCode,
814    detail: String,
815    details: Option<&std::collections::HashMap<String, Vec<String>>>,
816    explicit_type: Option<&'static str>,
817    request_id: Option<String>,
818    instance: Option<String>,
819    expose_internal_detail: bool,
820) -> ProblemDetails {
821    let has_validation_errors = details.is_some_and(|map| !map.is_empty());
822    let safe_detail = if status.is_server_error() && !expose_internal_detail {
823        server_error_detail(status)
824    } else {
825        detail
826    };
827
828    // When an explicit problem type URI is provided, derive the machine-readable
829    // code from its path segment (last path component, hyphens → underscores,
830    // prefixed with "autumn."). This avoids having to enumerate every error type
831    // in a separate match table.
832    //
833    // Example: "https://autumn.dev/problems/query-timeout" → "autumn.query_timeout"
834    let code = explicit_type.map_or_else(
835        || problem_code_for(status, has_validation_errors).to_owned(),
836        |etype| {
837            let slug = etype.rsplit('/').next().unwrap_or(etype);
838            format!("autumn.{}", slug.replace('-', "_"))
839        },
840    );
841
842    ProblemDetails {
843        type_uri: explicit_type
844            .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
845            .to_owned(),
846        title: problem_title_for(status, has_validation_errors).to_owned(),
847        status: status.as_u16(),
848        detail: safe_detail,
849        instance,
850        code,
851        request_id,
852        errors: validation_errors(details),
853    }
854}
855
856/// Serialize a Problem Details payload for middleware that cannot return
857/// `axum::Json` directly because its response body type is generic.
858#[must_use]
859pub(crate) fn problem_details_json_string(
860    status: StatusCode,
861    detail: impl Into<String>,
862    details: Option<&std::collections::HashMap<String, Vec<String>>>,
863    explicit_type: Option<&'static str>,
864    request_id: Option<String>,
865    instance: Option<String>,
866    expose_internal_detail: bool,
867) -> String {
868    let problem = problem_details(
869        status,
870        detail.into(),
871        details,
872        explicit_type,
873        request_id,
874        instance,
875        expose_internal_detail,
876    );
877    problem_details_to_json_string(&problem)
878}
879
880/// Serialize an already-built Problem Details payload.
881#[must_use]
882pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
883    serde_json::to_string(&problem).unwrap_or_else(|_| {
884        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()
885    })
886}
887
888fn validation_errors(
889    details: Option<&std::collections::HashMap<String, Vec<String>>>,
890) -> Vec<ProblemFieldError> {
891    let mut errors: Vec<_> = details
892        .into_iter()
893        .flat_map(std::collections::HashMap::iter)
894        .map(|(field, messages)| ProblemFieldError {
895            field: field.clone(),
896            messages: messages.clone(),
897        })
898        .collect();
899    errors.sort_by(|left, right| left.field.cmp(&right.field));
900    errors
901}
902
903const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
904    if has_validation_errors {
905        return "https://autumn.dev/problems/validation-failed";
906    }
907
908    match status {
909        StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
910        StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
911        StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
912        StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
913        StatusCode::GONE => "https://autumn.dev/problems/gone",
914        StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
915        StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
916        StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
917        StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
918        StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
919        StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
920        _ => "about:blank",
921    }
922}
923
924fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
925    if has_validation_errors {
926        return "Validation Failed";
927    }
928
929    match status {
930        StatusCode::BAD_REQUEST => "Bad Request",
931        StatusCode::UNAUTHORIZED => "Unauthorized",
932        StatusCode::FORBIDDEN => "Forbidden",
933        StatusCode::NOT_FOUND => "Not Found",
934        StatusCode::GONE => "Gone",
935        StatusCode::CONFLICT => "Conflict",
936        StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
937        StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
938        StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
939        StatusCode::NOT_IMPLEMENTED => "Not Implemented",
940        StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
941        _ => status.canonical_reason().unwrap_or("Error"),
942    }
943}
944
945fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
946    if has_validation_errors {
947        return "autumn.validation_failed";
948    }
949
950    match status {
951        StatusCode::BAD_REQUEST => "autumn.bad_request",
952        StatusCode::UNAUTHORIZED => "autumn.unauthorized",
953        StatusCode::FORBIDDEN => "autumn.forbidden",
954        StatusCode::NOT_FOUND => "autumn.not_found",
955        StatusCode::GONE => "autumn.gone",
956        StatusCode::CONFLICT => "autumn.conflict",
957        StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
958        StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
959        StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
960        StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
961        StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
962        _ if status.is_client_error() => "autumn.client_error",
963        _ if status.is_server_error() => "autumn.server_error",
964        _ => "autumn.error",
965    }
966}
967
968fn server_error_detail(status: StatusCode) -> String {
969    match status {
970        StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
971        StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
972        _ => "Internal server error".to_owned(),
973    }
974}
975
976impl IntoResponse for AutumnError {
977    fn into_response(self) -> Response {
978        let mut status = self.status;
979        let message = self.inner.to_string();
980        let mut problem_type = self.problem_type;
981
982        // Automatically map database query cancellation (statement timeout) to 503 Service Unavailable
983        let err_str = message.to_lowercase();
984        if err_str.contains("57014")
985            || err_str.contains("query_canceled")
986            || err_str.contains("canceling statement due to statement timeout")
987            || err_str.contains("statement timeout")
988            || err_str.contains("query canceled")
989        {
990            status = StatusCode::SERVICE_UNAVAILABLE;
991            problem_type = Some("https://autumn.dev/problems/query-timeout");
992        }
993
994        let details = self.details.clone();
995        let cache_idempotency_response = self.cache_idempotency_response;
996
997        // Stash error metadata for exception filters to inspect without
998        // parsing the response body.
999        let error_info = crate::middleware::AutumnErrorInfo {
1000            status,
1001            message: message.clone(),
1002            details: details.clone(),
1003            problem_type,
1004            #[cfg(debug_assertions)]
1005            backtrace_string: self.backtrace_string.clone(),
1006            #[cfg(not(debug_assertions))]
1007            backtrace_string: None,
1008        };
1009
1010        let body = problem_details(
1011            status,
1012            message,
1013            details.as_ref(),
1014            problem_type,
1015            None,
1016            None,
1017            true,
1018        );
1019        let mut response = (status, axum::Json(body)).into_response();
1020        response.headers_mut().insert(
1021            header::CONTENT_TYPE,
1022            HeaderValue::from_static("application/problem+json"),
1023        );
1024        if status == StatusCode::CONFLICT {
1025            response.headers_mut().insert(
1026                "HX-Trigger",
1027                HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
1028            );
1029        }
1030        if cache_idempotency_response {
1031            response
1032                .extensions_mut()
1033                .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
1034        }
1035        response.extensions_mut().insert(error_info);
1036        response
1037    }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use axum::http::StatusCode;
1044
1045    #[derive(Debug)]
1046    struct TestError(String);
1047
1048    impl std::fmt::Display for TestError {
1049        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1050            write!(f, "{}", self.0)
1051        }
1052    }
1053
1054    impl std::error::Error for TestError {}
1055
1056    #[derive(Debug)]
1057    struct WrappedError {
1058        message: String,
1059        source: TestError,
1060    }
1061
1062    impl std::fmt::Display for WrappedError {
1063        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1064            write!(f, "{}", self.message)
1065        }
1066    }
1067
1068    impl std::error::Error for WrappedError {
1069        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1070            Some(&self.source)
1071        }
1072    }
1073
1074    #[test]
1075    fn blanket_from_defaults_to_500() {
1076        let err: AutumnError = TestError("boom".into()).into();
1077        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1078    }
1079
1080    #[test]
1081    fn internal_server_error_is_500() {
1082        let err = AutumnError::internal_server_error(TestError("boom".into()));
1083        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1084    }
1085
1086    #[test]
1087    fn test_not_found_error() {
1088        let err = AutumnError::not_found(std::io::Error::other("no such user"));
1089        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1090    }
1091
1092    #[test]
1093    fn not_found_is_404() {
1094        let err = AutumnError::not_found(TestError("missing".into()));
1095        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1096    }
1097
1098    #[test]
1099    fn bad_request_is_400() {
1100        let err = AutumnError::bad_request(TestError("invalid input".into()));
1101        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1102    }
1103
1104    #[test]
1105    fn unprocessable_is_422() {
1106        let err = AutumnError::unprocessable(TestError("bad entity".into()));
1107        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1108    }
1109
1110    #[test]
1111    fn unauthorized_is_401() {
1112        let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1113        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1114    }
1115
1116    #[test]
1117    fn forbidden_is_403() {
1118        let err = AutumnError::forbidden(TestError("forbidden".into()));
1119        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1120    }
1121
1122    #[test]
1123    fn validation_is_422() {
1124        let mut details = std::collections::HashMap::new();
1125        details.insert("field".to_string(), vec!["error".to_string()]);
1126        let err = AutumnError::validation(details);
1127        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1128    }
1129
1130    #[test]
1131    fn service_unavailable_is_503() {
1132        let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1133        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1134    }
1135
1136    #[test]
1137    fn internal_server_error_msg_is_500() {
1138        let err = AutumnError::internal_server_error_msg("db failure");
1139        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1140        assert_eq!(err.to_string(), "db failure");
1141    }
1142
1143    #[test]
1144    fn not_found_msg_is_404() {
1145        let err = AutumnError::not_found_msg("no such user");
1146        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1147        assert_eq!(err.to_string(), "no such user");
1148    }
1149
1150    #[test]
1151    fn bad_request_msg_is_400() {
1152        let err = AutumnError::bad_request_msg("invalid input");
1153        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1154    }
1155
1156    #[test]
1157    fn unprocessable_msg_is_422() {
1158        let err = AutumnError::unprocessable_msg("title required");
1159        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1160    }
1161
1162    #[test]
1163    fn unauthorized_msg_is_401() {
1164        let err = AutumnError::unauthorized_msg("login required");
1165        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1166    }
1167
1168    #[test]
1169    fn forbidden_msg_is_403() {
1170        let err = AutumnError::forbidden_msg("no access");
1171        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1172    }
1173
1174    #[test]
1175    fn service_unavailable_msg_is_503() {
1176        let err = AutumnError::service_unavailable_msg("db down");
1177        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1178        assert_eq!(err.to_string(), "db down");
1179    }
1180
1181    #[test]
1182    fn with_status_overrides() {
1183        let err: AutumnError = TestError("forbidden".into()).into();
1184        let err = err.with_status(StatusCode::FORBIDDEN);
1185        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1186    }
1187
1188    #[test]
1189    fn display_uses_inner_message() {
1190        let err: AutumnError = TestError("something broke".into()).into();
1191        assert_eq!(err.to_string(), "something broke");
1192    }
1193
1194    #[test]
1195    fn source_chain_lists_inner_sources() {
1196        let err = AutumnError::internal_server_error(WrappedError {
1197            message: "failed to backfill".to_string(),
1198            source: TestError("database connection dropped".to_string()),
1199        });
1200
1201        assert_eq!(
1202            err.source_chain(),
1203            vec!["database connection dropped".to_string()]
1204        );
1205    }
1206
1207    #[test]
1208    fn into_response_has_correct_status() {
1209        let err = AutumnError::not_found(TestError("not found".into()));
1210        let response = err.into_response();
1211        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1212    }
1213
1214    #[tokio::test]
1215    async fn into_response_has_json_body() -> Result<(), axum::Error> {
1216        let err = AutumnError::not_found(TestError("not found".into()));
1217        let response = err.into_response();
1218
1219        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1220        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1221
1222        assert_eq!(json["status"], 404);
1223        assert_eq!(json["detail"], "not found");
1224        assert_eq!(json["code"], "autumn.not_found");
1225        Ok(())
1226    }
1227
1228    #[test]
1229    fn debug_shows_status_and_inner() {
1230        let err = AutumnError::bad_request(TestError("oops".into()));
1231        let debug = format!("{err:?}");
1232        assert!(debug.contains("AutumnError"));
1233        assert!(debug.contains("400"));
1234    }
1235
1236    #[tokio::test]
1237    async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1238        let err = AutumnError::unprocessable_msg("title required");
1239        let response = err.into_response();
1240
1241        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1242        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1243        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1244        assert_eq!(json["status"], 422);
1245        assert_eq!(json["detail"], "title required");
1246        assert_eq!(json["code"], "autumn.unprocessable_entity");
1247        Ok(())
1248    }
1249
1250    #[tokio::test]
1251    async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1252        let err = AutumnError::service_unavailable_msg("db down");
1253        let response = err.into_response();
1254
1255        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1256        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1257        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1258        assert_eq!(json["status"], 503);
1259        assert_eq!(json["detail"], "db down");
1260        assert_eq!(json["code"], "autumn.service_unavailable");
1261        Ok(())
1262    }
1263
1264    #[test]
1265    fn conflict_is_409() {
1266        let err = AutumnError::conflict(TestError("stale version".into()));
1267        assert_eq!(err.status(), StatusCode::CONFLICT);
1268    }
1269
1270    #[test]
1271    fn conflict_msg_is_409() {
1272        let err = AutumnError::conflict_msg("please reload and retry");
1273        assert_eq!(err.status(), StatusCode::CONFLICT);
1274        assert_eq!(err.to_string(), "please reload and retry");
1275    }
1276
1277    #[test]
1278    fn gone_is_410() {
1279        let err = AutumnError::gone(TestError("sunsetted".into()));
1280        assert_eq!(err.status(), StatusCode::GONE);
1281    }
1282
1283    #[test]
1284    fn gone_msg_is_410() {
1285        let err = AutumnError::gone_msg("API version has been sunsetted");
1286        assert_eq!(err.status(), StatusCode::GONE);
1287        assert_eq!(err.to_string(), "API version has been sunsetted");
1288    }
1289
1290    #[tokio::test]
1291    async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1292        let err = AutumnError::conflict_msg("version mismatch");
1293        let response = err.into_response();
1294
1295        assert_eq!(response.status(), StatusCode::CONFLICT);
1296        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1297        let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1298        assert_eq!(json["status"], 409);
1299        assert_eq!(json["detail"], "version mismatch");
1300        assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1301        assert_eq!(json["title"], "Conflict");
1302        Ok(())
1303    }
1304
1305    #[tokio::test]
1306    async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1307        let err = AutumnError::conflict_msg("version mismatch");
1308        let response = err.into_response();
1309
1310        assert_eq!(response.status(), StatusCode::CONFLICT);
1311        let hx_trigger = response
1312            .headers()
1313            .get("HX-Trigger")
1314            .expect("HX-Trigger header present");
1315        assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1316        Ok(())
1317    }
1318
1319    // ── unique_violation_field (issue #1032) ────────────────────────────────
1320
1321    #[cfg(feature = "db")]
1322    mod unique_violation_field_tests {
1323        use super::*;
1324        use crate::error::unique_violation_field;
1325
1326        #[derive(Debug)]
1327        struct FakeDbErrorInfo {
1328            constraint: Option<&'static str>,
1329        }
1330
1331        impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
1332            fn message(&self) -> &'static str {
1333                "duplicate key value violates unique constraint"
1334            }
1335            fn details(&self) -> Option<&str> {
1336                None
1337            }
1338            fn hint(&self) -> Option<&str> {
1339                None
1340            }
1341            fn table_name(&self) -> Option<&str> {
1342                None
1343            }
1344            fn column_name(&self) -> Option<&str> {
1345                None
1346            }
1347            fn constraint_name(&self) -> Option<&str> {
1348                self.constraint
1349            }
1350            fn statement_position(&self) -> Option<i32> {
1351                None
1352            }
1353        }
1354
1355        fn unique_violation(constraint: Option<&'static str>) -> diesel::result::Error {
1356            diesel::result::Error::DatabaseError(
1357                diesel::result::DatabaseErrorKind::UniqueViolation,
1358                Box::new(FakeDbErrorInfo { constraint }),
1359            )
1360        }
1361
1362        const MAPPING: &[(&str, &str, &str)] =
1363            &[("idx_users_email_unique", "email", "has already been taken")];
1364
1365        #[test]
1366        fn matches_constraint_name_to_field_and_message() {
1367            let err: AutumnError = AutumnError::internal_server_error(unique_violation(Some(
1368                "idx_users_email_unique",
1369            )));
1370            assert_eq!(
1371                unique_violation_field(&err, MAPPING),
1372                Some(("email", "has already been taken"))
1373            );
1374        }
1375
1376        #[test]
1377        fn returns_none_for_unrecognized_constraint() {
1378            let err: AutumnError =
1379                AutumnError::internal_server_error(unique_violation(Some("some_other_constraint")));
1380            assert_eq!(unique_violation_field(&err, MAPPING), None);
1381        }
1382
1383        #[test]
1384        fn returns_none_when_constraint_name_is_absent() {
1385            let err: AutumnError = AutumnError::internal_server_error(unique_violation(None));
1386            assert_eq!(unique_violation_field(&err, MAPPING), None);
1387        }
1388
1389        #[test]
1390        fn returns_none_for_non_unique_violation_db_errors() {
1391            let err: AutumnError =
1392                AutumnError::internal_server_error(diesel::result::Error::NotFound);
1393            assert_eq!(unique_violation_field(&err, MAPPING), None);
1394        }
1395
1396        #[test]
1397        fn returns_none_for_non_db_errors() {
1398            let err = AutumnError::internal_server_error_msg("plain string error");
1399            assert_eq!(unique_violation_field(&err, MAPPING), None);
1400        }
1401
1402        #[test]
1403        fn recovers_diesel_error_through_repository_style_blanket_from() {
1404            // Mirrors a generated repository method's `.execute(...).await?`
1405            // -- the diesel error is converted to `AutumnError` via the
1406            // blanket `From` impl before `unique_violation_field` ever sees
1407            // it, same as `repo.save(...).await?`'s already-mapped error.
1408            fn insert() -> Result<(), diesel::result::Error> {
1409                Err(unique_violation(Some("idx_users_email_unique")))
1410            }
1411            fn handler() -> Result<(), AutumnError> {
1412                insert()?;
1413                Ok(())
1414            }
1415            let err = handler().unwrap_err();
1416            assert_eq!(
1417                unique_violation_field(&err, MAPPING),
1418                Some(("email", "has already been taken"))
1419            );
1420        }
1421    }
1422}