Skip to main content

arcly_http/web/
error.rs

1//! HTTP error domain.
2//!
3//! `HttpError` is the open trait every domain error implements; `HttpException`
4//! is the type-erased box that handlers actually return. The framework's stock
5//! errors (`NotFound`, `Unauthorized`, `BadRequest`, `Validation`,
6//! `ServiceUnavailable`, `Conflict`, `TooManyRequests`, `Internal`) all
7//! implement `HttpError` and gain `From<…> for HttpException` automatically,
8//! so handlers compose with `?` regardless of which concrete error variant
9//! they construct.
10
11use std::borrow::Cow;
12use std::error::Error as StdError;
13use std::fmt;
14
15use axum::http::StatusCode;
16use axum::response::{IntoResponse, Response};
17use serde::Serialize;
18
19// ─── Field-level validation entry ────────────────────────────────────────
20#[derive(Debug, Serialize, Clone)]
21pub struct FieldError {
22    pub field: String,
23    pub code: String,
24    pub message: String,
25}
26
27// ─── ProblemDetails (RFC 7807) ───────────────────────────────────────────
28#[derive(Debug, Serialize, Clone)]
29pub struct ProblemDetails {
30    #[serde(rename = "type")]
31    pub kind: Cow<'static, str>,
32    pub title: Cow<'static, str>,
33    pub status: u16,
34    pub detail: Cow<'static, str>,
35    #[serde(skip_serializing_if = "Vec::is_empty", default)]
36    pub errors: Vec<FieldError>,
37}
38
39impl ProblemDetails {
40    #[inline]
41    pub fn new(
42        status: u16,
43        kind: &'static str,
44        title: &'static str,
45        detail: impl Into<Cow<'static, str>>,
46    ) -> Self {
47        Self {
48            kind: Cow::Borrowed(kind),
49            title: Cow::Borrowed(title),
50            status,
51            detail: detail.into(),
52            errors: Vec::new(),
53        }
54    }
55    #[inline]
56    pub fn with_errors(mut self, errors: Vec<FieldError>) -> Self {
57        self.errors = errors;
58        self
59    }
60}
61
62// ─── The open trait ──────────────────────────────────────────────────────
63/// Every domain error type implements `HttpError`. The framework converts to
64/// a `ProblemDetails` body and the appropriate status code.
65pub trait HttpError: StdError + Send + Sync + 'static {
66    fn problem(&self) -> ProblemDetails;
67}
68
69// ─── HttpException — the type-erased return shape ────────────────────────
70/// Boxed `HttpError`. Handlers return `Result<T, HttpException>`; `?` works
71/// across any user-defined error via the blanket `From<E: HttpError>` impl.
72pub struct HttpException(pub Box<dyn HttpError>);
73
74impl fmt::Debug for HttpException {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "HttpException({})", self.0)
77    }
78}
79
80impl fmt::Display for HttpException {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        fmt::Display::fmt(&self.0, f)
83    }
84}
85
86impl<E: HttpError + 'static> From<E> for HttpException {
87    #[inline]
88    fn from(e: E) -> Self {
89        Self(Box::new(e))
90    }
91}
92
93impl IntoResponse for HttpException {
94    fn into_response(self) -> Response {
95        let p = self.0.problem();
96        let status = StatusCode::from_u16(p.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
97        (status, axum::Json(p)).into_response()
98    }
99}
100
101// ─── Stock errors ────────────────────────────────────────────────────────
102macro_rules! stock_error {
103    ($name:ident, $status:expr, $kind:expr, $title:expr, $default_detail:expr) => {
104        #[derive(Debug, Clone)]
105        pub struct $name {
106            pub detail: Cow<'static, str>,
107        }
108        impl $name {
109            #[inline]
110            pub fn new(detail: impl Into<Cow<'static, str>>) -> Self {
111                Self {
112                    detail: detail.into(),
113                }
114            }
115        }
116        impl Default for $name {
117            fn default() -> Self {
118                Self {
119                    detail: Cow::Borrowed($default_detail),
120                }
121            }
122        }
123        impl fmt::Display for $name {
124            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125                write!(f, "{}: {}", $title, self.detail)
126            }
127        }
128        impl StdError for $name {}
129        impl HttpError for $name {
130            fn problem(&self) -> ProblemDetails {
131                ProblemDetails::new($status, $kind, $title, self.detail.clone())
132            }
133        }
134    };
135}
136
137stock_error!(
138    NotFound,
139    404,
140    "not-found",
141    "Not Found",
142    "resource not found"
143);
144stock_error!(
145    Unauthorized,
146    401,
147    "unauthorized",
148    "Unauthorized",
149    "authentication required"
150);
151stock_error!(Forbidden, 403, "forbidden", "Forbidden", "access denied");
152stock_error!(
153    BadRequest,
154    400,
155    "bad-request",
156    "Bad Request",
157    "malformed request"
158);
159stock_error!(Conflict, 409, "conflict", "Conflict", "resource conflict");
160stock_error!(
161    TooManyRequests,
162    429,
163    "too-many-requests",
164    "Too Many Requests",
165    "rate limit exceeded"
166);
167stock_error!(
168    ServiceUnavailable,
169    503,
170    "service-unavailable",
171    "Service Unavailable",
172    "downstream unavailable"
173);
174stock_error!(
175    Internal,
176    500,
177    "internal",
178    "Internal Server Error",
179    "internal error"
180);
181stock_error!(
182    GatewayTimeout,
183    504,
184    "gateway-timeout",
185    "Gateway Timeout",
186    "handler deadline exceeded"
187);
188
189// Validation gets its own type because it carries structured per-field errors.
190#[derive(Debug, Clone)]
191pub struct Validation {
192    pub errors: Vec<FieldError>,
193}
194impl Validation {
195    #[inline]
196    pub fn new(errors: Vec<FieldError>) -> Self {
197        Self { errors }
198    }
199}
200impl fmt::Display for Validation {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "validation failed ({} error(s))", self.errors.len())
203    }
204}
205impl StdError for Validation {}
206impl HttpError for Validation {
207    fn problem(&self) -> ProblemDetails {
208        ProblemDetails::new(
209            422,
210            "validation",
211            "Unprocessable Entity",
212            "payload failed validation",
213        )
214        .with_errors(self.errors.clone())
215    }
216}
217
218impl From<validator::ValidationErrors> for Validation {
219    fn from(errs: validator::ValidationErrors) -> Self {
220        let mut out = Vec::new();
221        for (field, kinds) in errs.field_errors() {
222            for k in kinds {
223                let message = k
224                    .message
225                    .as_ref()
226                    .map(|m| m.to_string())
227                    .unwrap_or_else(|| format!("failed `{}` rule", k.code));
228                out.push(FieldError {
229                    field: field.to_string(),
230                    code: k.code.to_string(),
231                    message,
232                });
233            }
234        }
235        Self::new(out)
236    }
237}
238
239// ─── Back-compat shim: the previous closed `Error` enum ──────────────────
240//
241// Kept so existing guards / extractors keep building. The variants
242// trampoline to the new typed errors via `From<Error> for HttpException`.
243#[derive(Debug)]
244pub enum Error {
245    NotFound,
246    Unauthorized,
247    Forbidden,
248    TooManyRequests,
249    ServiceUnavailable(&'static str),
250    BadRequest(&'static str),
251    Validation(Vec<FieldError>),
252    Internal(&'static str),
253}
254
255impl fmt::Display for Error {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            Error::NotFound => write!(f, "not found"),
259            Error::Unauthorized => write!(f, "unauthorized"),
260            Error::Forbidden => write!(f, "forbidden"),
261            Error::TooManyRequests => write!(f, "rate limit exceeded"),
262            Error::ServiceUnavailable(d) => write!(f, "service unavailable: {d}"),
263            Error::BadRequest(d) => write!(f, "bad request: {d}"),
264            Error::Validation(_) => write!(f, "validation failed"),
265            Error::Internal(d) => write!(f, "internal: {d}"),
266        }
267    }
268}
269impl StdError for Error {}
270
271impl HttpError for Error {
272    fn problem(&self) -> ProblemDetails {
273        match self {
274            Error::NotFound => NotFound::default().problem(),
275            Error::Unauthorized => Unauthorized::default().problem(),
276            Error::Forbidden => Forbidden::default().problem(),
277            Error::TooManyRequests => TooManyRequests::default().problem(),
278            Error::ServiceUnavailable(d) => ServiceUnavailable::new(*d).problem(),
279            Error::BadRequest(d) => BadRequest::new(*d).problem(),
280            Error::Validation(v) => Validation::new(v.clone()).problem(),
281            Error::Internal(d) => Internal::new(*d).problem(),
282        }
283    }
284}
285
286impl IntoResponse for Error {
287    fn into_response(self) -> Response {
288        HttpException::from(self).into_response()
289    }
290}
291
292impl From<validator::ValidationErrors> for Error {
293    fn from(errs: validator::ValidationErrors) -> Self {
294        Error::Validation(Validation::from(errs).errors)
295    }
296}