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    /// `400` carrying a structured validation failure (per-field + non-field).
39    Validation(WriteError),
40    /// `500` — a database error. Logged server-side; the client sees an opaque
41    /// "internal server error" (WEB-5: never leak DB internals).
42    Database(sqlx::Error),
43    /// `500` — any other internal error — [`ApiError::internal`]. Logged; opaque.
44    Internal(String),
45}
46
47impl ApiError {
48    /// A `404 Not Found` with a client-visible message.
49    pub fn not_found(msg: impl Into<String>) -> Self {
50        Self::NotFound(msg.into())
51    }
52    /// A `400 Bad Request` with a client-visible message.
53    pub fn bad_request(msg: impl Into<String>) -> Self {
54        Self::BadRequest(msg.into())
55    }
56    /// A `500` whose message is logged server-side but never sent to the client.
57    pub fn internal(msg: impl Into<String>) -> Self {
58        Self::Internal(msg.into())
59    }
60}
61
62impl From<sqlx::Error> for ApiError {
63    fn from(e: sqlx::Error) -> Self {
64        Self::Database(e)
65    }
66}
67
68impl From<WriteError> for ApiError {
69    fn from(e: WriteError) -> Self {
70        // A validation failure (required field, FK-not-found, format rule, …) is
71        // a 400 the client can act on; a true infra/serialization failure is a
72        // 500 they can't.
73        if e.is_validation() {
74            return Self::Validation(e);
75        }
76        match e {
77            WriteError::Sqlx(s) => Self::Database(s),
78            other => Self::Internal(other.to_string()),
79        }
80    }
81}
82
83impl From<DynError> for ApiError {
84    fn from(e: DynError) -> Self {
85        match e {
86            DynError::Write(w) => Self::from(w),
87            DynError::Sqlx(s) => Self::from(s),
88        }
89    }
90}
91
92impl std::fmt::Display for ApiError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            ApiError::NotFound(m) | ApiError::BadRequest(m) | ApiError::Internal(m) => {
96                write!(f, "{m}")
97            }
98            ApiError::Validation(e) => write!(f, "{e}"),
99            ApiError::Database(e) => write!(f, "database error: {e}"),
100        }
101    }
102}
103
104impl std::error::Error for ApiError {}
105
106/// A single-message JSON error body: `{"error": "...", "code": "..."}`.
107fn json_error(status: StatusCode, code: &str, error: &str) -> Response {
108    (
109        status,
110        Json(serde_json::json!({ "error": error, "code": code })),
111    )
112        .into_response()
113}
114
115impl IntoResponse for ApiError {
116    fn into_response(self) -> Response {
117        match self {
118            ApiError::NotFound(msg) => json_error(StatusCode::NOT_FOUND, "not_found", &msg),
119            ApiError::BadRequest(msg) => json_error(StatusCode::BAD_REQUEST, "bad_request", &msg),
120            ApiError::Validation(e) => (
121                StatusCode::BAD_REQUEST,
122                Json(serde_json::json!({
123                    "code": e.code(),
124                    "field_errors": e.field_errors(),
125                    "non_field_errors": e.non_field_errors(),
126                })),
127            )
128                .into_response(),
129            ApiError::Database(e) => {
130                tracing::error!(error = %e, "ApiError: database error");
131                json_error(
132                    StatusCode::INTERNAL_SERVER_ERROR,
133                    "database_error",
134                    "internal server error",
135                )
136            }
137            ApiError::Internal(msg) => {
138                tracing::error!(detail = %msg, "ApiError: internal error");
139                json_error(
140                    StatusCode::INTERNAL_SERVER_ERROR,
141                    "internal_error",
142                    "internal server error",
143                )
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::web::{IntoResponse, StatusCode};
153
154    #[test]
155    fn write_error_validation_becomes_a_400_with_the_field() {
156        let e = WriteError::RequiredFieldMissing {
157            field: "email".into(),
158        };
159        let api = ApiError::from(e);
160        assert_eq!(api.into_response().status(), StatusCode::BAD_REQUEST);
161    }
162
163    #[test]
164    fn a_bare_sqlx_error_becomes_an_opaque_500() {
165        let api = ApiError::from(sqlx::Error::RowNotFound);
166        assert_eq!(
167            api.into_response().status(),
168            StatusCode::INTERNAL_SERVER_ERROR
169        );
170    }
171
172    #[test]
173    fn dynerror_routes_write_to_400_and_sqlx_to_500() {
174        let v = ApiError::from(DynError::Write(WriteError::RequiredFieldMissing {
175            field: "x".into(),
176        }));
177        assert_eq!(v.into_response().status(), StatusCode::BAD_REQUEST);
178        let s = ApiError::from(DynError::Sqlx(sqlx::Error::RowNotFound));
179        assert_eq!(
180            s.into_response().status(),
181            StatusCode::INTERNAL_SERVER_ERROR
182        );
183    }
184
185    #[test]
186    fn explicit_constructors_carry_their_status() {
187        assert_eq!(
188            ApiError::not_found("nope").into_response().status(),
189            StatusCode::NOT_FOUND
190        );
191        assert_eq!(
192            ApiError::bad_request("bad").into_response().status(),
193            StatusCode::BAD_REQUEST
194        );
195        assert_eq!(
196            ApiError::internal("boom").into_response().status(),
197            StatusCode::INTERNAL_SERVER_ERROR
198        );
199    }
200}