Skip to main content

umbral_core/
api_error.rs

1//! `ApiError` — a handler-facing error any plain axum handler can return.
2//!
3//! Batteries-included apps write non-CRUD handlers that touch the ORM. Without
4//! this, every one re-declares `fn err500<E: Display>(e) -> (StatusCode, String)`
5//! and sprinkles `.map_err(err500)?` on every terminal (the single highest-volume
6//! boilerplate observed in a live consumer). `ApiError` implements
7//! `From<WriteError>` / `From<sqlx::Error>` / `From<DynError>` and `IntoResponse`,
8//! so a handler returns `Result<T, ApiError>` and uses a bare `?`:
9//!
10//! ```ignore
11//! use umbral::web::{ApiError, Json};
12//!
13//! async fn get_post(Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
14//!     let post = Post::objects().filter(post::ID.eq(id)).first().await?   // sqlx::Error -> 500
15//!         .ok_or_else(|| ApiError::not_found("no such post"))?;           // -> 404
16//!     Ok(Json(post))
17//! }
18//! ```
19//!
20//! Safe by default (WEB-5): a database/internal error logs the real cause
21//! server-side and hands the client an opaque 500 — table names, SQL fragments
22//! and constraint internals never reach the wire. A `WriteError` that is a
23//! *validation* failure (required field, FK-not-found, format rule, …) becomes a
24//! 400 carrying the structured per-field error map.
25
26use crate::orm::DynError;
27use crate::orm::write::WriteError;
28use crate::web::{IntoResponse, Json, Response, StatusCode};
29
30/// A handler-facing error. Build explicit ones with the constructors, or let `?`
31/// convert an ORM error (`sqlx::Error` / `WriteError` / `DynError`).
32#[derive(Debug)]
33pub enum ApiError {
34    /// `404` — [`ApiError::not_found`].
35    NotFound(String),
36    /// `400` with a single message — [`ApiError::bad_request`].
37    BadRequest(String),
38    /// `401` — no credentials, or credentials that resolved to nobody (gaps3 #62).
39    ///
40    /// Its absence is why real handlers kept hand-rolling `(StatusCode, String)`: a
41    /// login-gated page needs a 401, `ApiError` could not express one, so the whole
42    /// handler fell back to the tuple — and the tuple's 500 arm is `err.to_string()`,
43    /// which puts the database's own error text on the page. A missing variant is not a
44    /// cosmetic gap; it pushes people onto the leaky path.
45    Unauthorized(String),
46    /// `403` — authenticated, but not allowed.
47    Forbidden(String),
48    /// `429` — rate-limited.
49    TooManyRequests(String),
50    /// `400` carrying a structured validation failure (per-field + non-field).
51    Validation(WriteError),
52    /// `500` — a database error. Logged server-side; the client sees an opaque
53    /// "internal server error" (WEB-5: never leak DB internals).
54    Database(sqlx::Error),
55    /// `500` — any other internal error — [`ApiError::internal`]. Logged; opaque.
56    Internal(String),
57}
58
59impl ApiError {
60    /// A `404 Not Found` with a client-visible message.
61    pub fn not_found(msg: impl Into<String>) -> Self {
62        Self::NotFound(msg.into())
63    }
64    /// A `401 Unauthorized` with a client-visible message.
65    pub fn unauthorized(msg: impl Into<String>) -> Self {
66        Self::Unauthorized(msg.into())
67    }
68    /// A `403 Forbidden` with a client-visible message.
69    pub fn forbidden(msg: impl Into<String>) -> Self {
70        Self::Forbidden(msg.into())
71    }
72    /// A `429 Too Many Requests` with a client-visible message.
73    pub fn too_many_requests(msg: impl Into<String>) -> Self {
74        Self::TooManyRequests(msg.into())
75    }
76    /// A `400 Bad Request` with a client-visible message.
77    pub fn bad_request(msg: impl Into<String>) -> Self {
78        Self::BadRequest(msg.into())
79    }
80    /// A `500` whose message is logged server-side but never sent to the client.
81    pub fn internal(msg: impl Into<String>) -> Self {
82        Self::Internal(msg.into())
83    }
84}
85
86impl From<sqlx::Error> for ApiError {
87    fn from(e: sqlx::Error) -> Self {
88        Self::Database(e)
89    }
90}
91
92/// gaps3 #57 — so `umbral::templates::render(...)?` works with a bare `?`.
93///
94/// Rendering a template is the single most common fallible line in an HTML handler, and
95/// without this impl `ApiError` could not be that handler's error type at all. Which is
96/// how every example ended up hand-rolling `fn internal_error(e) -> (StatusCode, String)`
97/// — and that helper hands `e.to_string()` straight to the browser, so a missing table
98/// or a bad column name is printed to whoever asked for the page.
99///
100/// A broken template is a bug in the app, never in the request: it becomes an opaque 500
101/// with the real cause logged server-side, the same posture as a database error.
102impl From<crate::templates::TemplateError> for ApiError {
103    fn from(e: crate::templates::TemplateError) -> Self {
104        Self::Internal(e.to_string())
105    }
106}
107
108impl From<WriteError> for ApiError {
109    fn from(e: WriteError) -> Self {
110        // A validation failure (required field, FK-not-found, format rule, …) is
111        // a 400 the client can act on; a true infra/serialization failure is a
112        // 500 they can't.
113        if e.is_validation() {
114            return Self::Validation(e);
115        }
116        match e {
117            WriteError::Sqlx(s) => Self::Database(s),
118            other => Self::Internal(other.to_string()),
119        }
120    }
121}
122
123impl From<DynError> for ApiError {
124    fn from(e: DynError) -> Self {
125        match e {
126            DynError::Write(w) => Self::from(w),
127            DynError::Sqlx(s) => Self::from(s),
128        }
129    }
130}
131
132impl std::fmt::Display for ApiError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            ApiError::NotFound(m)
136            | ApiError::BadRequest(m)
137            | ApiError::Unauthorized(m)
138            | ApiError::Forbidden(m)
139            | ApiError::TooManyRequests(m)
140            | ApiError::Internal(m) => {
141                write!(f, "{m}")
142            }
143            ApiError::Validation(e) => write!(f, "{e}"),
144            ApiError::Database(e) => write!(f, "database error: {e}"),
145        }
146    }
147}
148
149impl std::error::Error for ApiError {}
150
151/// A single-message JSON error body: `{"error": "...", "code": "..."}`.
152fn json_error(status: StatusCode, code: &str, error: &str) -> Response {
153    (
154        status,
155        Json(serde_json::json!({ "error": error, "code": code })),
156    )
157        .into_response()
158}
159
160impl IntoResponse for ApiError {
161    fn into_response(self) -> Response {
162        match self {
163            ApiError::NotFound(msg) => json_error(StatusCode::NOT_FOUND, "not_found", &msg),
164            ApiError::BadRequest(msg) => json_error(StatusCode::BAD_REQUEST, "bad_request", &msg),
165            // These three carry a message the DEVELOPER wrote ("Please log in to post a
166            // note."), not text an error type produced — so they are safe to send. That is
167            // the line that matters: Database/Internal have their cause logged and never
168            // surfaced, precisely because that text comes from the database.
169            ApiError::Unauthorized(msg) => {
170                json_error(StatusCode::UNAUTHORIZED, "unauthorized", &msg)
171            }
172            ApiError::Forbidden(msg) => json_error(StatusCode::FORBIDDEN, "forbidden", &msg),
173            ApiError::TooManyRequests(msg) => {
174                json_error(StatusCode::TOO_MANY_REQUESTS, "too_many_requests", &msg)
175            }
176            ApiError::Validation(e) => (
177                StatusCode::BAD_REQUEST,
178                Json(serde_json::json!({
179                    "code": e.code(),
180                    "field_errors": e.field_errors(),
181                    "non_field_errors": e.non_field_errors(),
182                })),
183            )
184                .into_response(),
185            ApiError::Database(e) => {
186                tracing::error!(error = %e, "ApiError: database error");
187                json_error(
188                    StatusCode::INTERNAL_SERVER_ERROR,
189                    "database_error",
190                    "internal server error",
191                )
192            }
193            ApiError::Internal(msg) => {
194                tracing::error!(detail = %msg, "ApiError: internal error");
195                json_error(
196                    StatusCode::INTERNAL_SERVER_ERROR,
197                    "internal_error",
198                    "internal server error",
199                )
200            }
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::web::{IntoResponse, StatusCode};
209
210    #[test]
211    fn write_error_validation_becomes_a_400_with_the_field() {
212        let e = WriteError::RequiredFieldMissing {
213            field: "email".into(),
214        };
215        let api = ApiError::from(e);
216        assert_eq!(api.into_response().status(), StatusCode::BAD_REQUEST);
217    }
218
219    #[test]
220    fn a_bare_sqlx_error_becomes_an_opaque_500() {
221        let api = ApiError::from(sqlx::Error::RowNotFound);
222        assert_eq!(
223            api.into_response().status(),
224            StatusCode::INTERNAL_SERVER_ERROR
225        );
226    }
227
228    #[test]
229    fn dynerror_routes_write_to_400_and_sqlx_to_500() {
230        let v = ApiError::from(DynError::Write(WriteError::RequiredFieldMissing {
231            field: "x".into(),
232        }));
233        assert_eq!(v.into_response().status(), StatusCode::BAD_REQUEST);
234        let s = ApiError::from(DynError::Sqlx(sqlx::Error::RowNotFound));
235        assert_eq!(
236            s.into_response().status(),
237            StatusCode::INTERNAL_SERVER_ERROR
238        );
239    }
240
241    #[test]
242    fn explicit_constructors_carry_their_status() {
243        assert_eq!(
244            ApiError::not_found("nope").into_response().status(),
245            StatusCode::NOT_FOUND
246        );
247        assert_eq!(
248            ApiError::bad_request("bad").into_response().status(),
249            StatusCode::BAD_REQUEST
250        );
251        assert_eq!(
252            ApiError::internal("boom").into_response().status(),
253            StatusCode::INTERNAL_SERVER_ERROR
254        );
255    }
256}