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}