Skip to main content

anvil_core/
error.rs

1//! Unified error type for Anvil. Implements `IntoResponse` so handlers `?`-propagate freely.
2//!
3//! ## JSON shape
4//!
5//! `Error::into_response` always serializes to **Laravel's standard JSON
6//! shape** so a Laravel-trained frontend can consume Anvil errors without
7//! changes:
8//!
9//! ```jsonc
10//! // Validation errors (HTTP 422):
11//! {
12//!   "message": "The given data was invalid.",
13//!   "errors": {
14//!     "email": ["The email must be a valid email address."],
15//!     "password": ["The password must be at least 8 characters."]
16//!   }
17//! }
18//!
19//! // Everything else (HTTP 401 / 403 / 404 / 409 / 500 / …):
20//! {
21//!   "message": "forbidden: this resource belongs to another user"
22//! }
23//! ```
24//!
25//! Don't hand-build `(StatusCode, Json(json!({"error": "msg"})))` tuples —
26//! that produces a different shape than the framework's `?` propagation,
27//! and a Laravel-shaped client choking on the inconsistency will be your
28//! first regression. Use the `Error::*` variants:
29//!
30//! ```ignore
31//! return Err(Error::Forbidden("not yours".into()));      // {"message": "forbidden: not yours"}
32//! return Err(Error::NotFound);                            // {"message": "not found"}
33//! return Err(Error::bad_request("missing parameter"));    // {"message": "bad request: missing parameter"}
34//! ```
35//!
36//! For ad-hoc validation errors outside a `FormRequest`:
37//!
38//! ```ignore
39//! let mut errs = ValidationErrors::new();
40//! errs.add("email", "email already in use");
41//! return Err(Error::Validation(errs));   // → 422, {"message": ..., "errors": {...}}
42//! ```
43
44use axum::http::StatusCode;
45use axum::response::{IntoResponse, Response};
46use axum::Json;
47use serde_json::json;
48use thiserror::Error;
49
50pub type Result<T, E = Error> = std::result::Result<T, E>;
51
52#[derive(Debug, Error)]
53pub enum Error {
54    #[error("not found")]
55    NotFound,
56
57    #[error("unauthenticated")]
58    Unauthenticated,
59
60    #[error("forbidden: {0}")]
61    Forbidden(String),
62
63    #[error("validation failed")]
64    Validation(ValidationErrors),
65
66    #[error("bad request: {0}")]
67    BadRequest(String),
68
69    #[error("conflict: {0}")]
70    Conflict(String),
71
72    #[error("database error: {0}")]
73    Database(#[from] sqlx::Error),
74
75    #[error("io error: {0}")]
76    Io(#[from] std::io::Error),
77
78    #[error("serialization error: {0}")]
79    Serialization(#[from] serde_json::Error),
80
81    #[error("config error: {0}")]
82    Config(String),
83
84    #[error("template error: {0}")]
85    Template(String),
86
87    #[error("queue error: {0}")]
88    Queue(String),
89
90    #[error("mail error: {0}")]
91    Mail(String),
92
93    #[error("cache error: {0}")]
94    Cache(String),
95
96    #[error("storage error: {0}")]
97    Storage(String),
98
99    #[error("internal server error: {0}")]
100    Internal(String),
101
102    #[error("{0}")]
103    Other(#[from] anyhow::Error),
104}
105
106#[derive(Debug, Clone, Default, serde::Serialize)]
107pub struct ValidationErrors {
108    pub errors: indexmap::IndexMap<String, Vec<String>>,
109}
110
111impl ValidationErrors {
112    pub fn new() -> Self {
113        Self {
114            errors: indexmap::IndexMap::new(),
115        }
116    }
117
118    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
119        self.errors
120            .entry(field.into())
121            .or_default()
122            .push(message.into());
123    }
124
125    pub fn is_empty(&self) -> bool {
126        self.errors.is_empty()
127    }
128}
129
130impl std::fmt::Display for ValidationErrors {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        for (field, msgs) in &self.errors {
133            for msg in msgs {
134                writeln!(f, "{field}: {msg}")?;
135            }
136        }
137        Ok(())
138    }
139}
140
141impl Error {
142    pub fn status(&self) -> StatusCode {
143        match self {
144            Error::NotFound => StatusCode::NOT_FOUND,
145            Error::Unauthenticated => StatusCode::UNAUTHORIZED,
146            Error::Forbidden(_) => StatusCode::FORBIDDEN,
147            Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
148            Error::BadRequest(_) => StatusCode::BAD_REQUEST,
149            Error::Conflict(_) => StatusCode::CONFLICT,
150            Error::Database(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND,
151            _ => StatusCode::INTERNAL_SERVER_ERROR,
152        }
153    }
154
155    pub fn forbidden(msg: impl Into<String>) -> Self {
156        Error::Forbidden(msg.into())
157    }
158
159    pub fn bad_request(msg: impl Into<String>) -> Self {
160        Error::BadRequest(msg.into())
161    }
162
163    pub fn internal(msg: impl Into<String>) -> Self {
164        Error::Internal(msg.into())
165    }
166}
167
168impl IntoResponse for Error {
169    fn into_response(self) -> Response {
170        let status = self.status();
171        let body = match &self {
172            Error::Validation(v) => json!({
173                "message": "The given data was invalid.",
174                "errors": v.errors,
175            }),
176            other => json!({
177                "message": other.to_string(),
178            }),
179        };
180
181        if matches!(
182            self,
183            Error::Internal(_) | Error::Database(_) | Error::Other(_)
184        ) {
185            tracing::error!(error = %self, "internal error response");
186        }
187
188        (status, Json(body)).into_response()
189    }
190}
191
192impl From<garde::Report> for Error {
193    fn from(report: garde::Report) -> Self {
194        let mut errors = ValidationErrors::new();
195        for (path, err) in report.iter() {
196            errors.add(path.to_string(), err.to_string());
197        }
198        Error::Validation(errors)
199    }
200}
201
202impl From<cast_core::Error> for Error {
203    fn from(err: cast_core::Error) -> Self {
204        match err {
205            cast_core::Error::Sqlx(e) => Error::Database(e),
206            cast_core::Error::NotFound => Error::NotFound,
207            other => Error::Internal(other.to_string()),
208        }
209    }
210}