Skip to main content

axum_api_kit/
error.rs

1use axum::{http::StatusCode, Json};
2use serde::Serialize;
3use serde_json::Value;
4use std::fmt;
5
6/// A machine-readable JSON error body.
7///
8/// Serializes as:
9/// ```json
10/// { "code": "NOT_FOUND", "message": "item not found" }
11/// { "code": "VALIDATION_ERROR", "message": "invalid input", "details": { "field": "name" } }
12/// ```
13///
14/// Use the factory methods to get a `(StatusCode, Json<ApiError>)` tuple, which implements
15/// [`IntoResponse`] and can be returned directly from Axum handlers.
16///
17/// # Example
18///
19/// ```rust
20/// use axum::response::IntoResponse;
21/// use axum_api_kit::ApiError;
22///
23/// async fn handler() -> impl IntoResponse {
24///     ApiError::not_found("item not found")
25/// }
26/// ```
27#[derive(Debug, Clone, Serialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub struct ApiError {
30    /// A short, stable, machine-readable error identifier. Use `SCREAMING_SNAKE_CASE`.
31    pub code: String,
32    /// A human-readable description of the error.
33    pub message: String,
34    /// Optional structured details (field-level validation errors, etc.).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
37    pub details: Option<Value>,
38}
39
40impl ApiError {
41    /// Construct a bare `ApiError` without a bundled status code.
42    ///
43    /// Prefer the factory methods ([`not_found`](Self::not_found), etc.) when returning
44    /// responses directly from handlers.
45    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
46        Self {
47            code: code.into(),
48            message: message.into(),
49            details: None,
50        }
51    }
52
53    /// Attach structured details to this error.
54    pub fn with_details(mut self, details: Value) -> Self {
55        self.details = Some(details);
56        self
57    }
58
59    // --- Factory helpers ---
60    // Each returns (StatusCode, Json<ApiError>) which implements IntoResponse.
61
62    /// `400 Bad Request` with the provided `code` and `message`.
63    pub fn bad_request(
64        code: impl Into<String>,
65        message: impl Into<String>,
66    ) -> (StatusCode, Json<Self>) {
67        (StatusCode::BAD_REQUEST, Json(Self::new(code, message)))
68    }
69
70    /// `401 Unauthorized` - `code` defaults to `"AUTH_REQUIRED"`.
71    pub fn unauthorized(message: impl Into<String>) -> (StatusCode, Json<Self>) {
72        (
73            StatusCode::UNAUTHORIZED,
74            Json(Self::new("AUTH_REQUIRED", message)),
75        )
76    }
77
78    /// `403 Forbidden` - `code` defaults to `"FORBIDDEN"`.
79    pub fn forbidden(message: impl Into<String>) -> (StatusCode, Json<Self>) {
80        (StatusCode::FORBIDDEN, Json(Self::new("FORBIDDEN", message)))
81    }
82
83    /// `404 Not Found` - `code` defaults to `"NOT_FOUND"`.
84    pub fn not_found(message: impl Into<String>) -> (StatusCode, Json<Self>) {
85        (StatusCode::NOT_FOUND, Json(Self::new("NOT_FOUND", message)))
86    }
87
88    /// `409 Conflict` - `code` defaults to `"CONFLICT"`.
89    pub fn conflict(message: impl Into<String>) -> (StatusCode, Json<Self>) {
90        (StatusCode::CONFLICT, Json(Self::new("CONFLICT", message)))
91    }
92
93    /// `422 Unprocessable Entity` - `code` defaults to `"VALIDATION_ERROR"`.
94    pub fn unprocessable_entity(message: impl Into<String>) -> (StatusCode, Json<Self>) {
95        (
96            StatusCode::UNPROCESSABLE_ENTITY,
97            Json(Self::new("VALIDATION_ERROR", message)),
98        )
99    }
100
101    /// `500 Internal Server Error` - `code` defaults to `"INTERNAL_ERROR"`.
102    pub fn internal(message: impl Into<String>) -> (StatusCode, Json<Self>) {
103        (
104            StatusCode::INTERNAL_SERVER_ERROR,
105            Json(Self::new("INTERNAL_ERROR", message)),
106        )
107    }
108
109    /// `500 Internal Server Error` for database failures - `code` is `"DB_ERROR"`.
110    pub fn db_error() -> (StatusCode, Json<Self>) {
111        (
112            StatusCode::INTERNAL_SERVER_ERROR,
113            Json(Self::new("DB_ERROR", "database error")),
114        )
115    }
116
117    /// `429 Too Many Requests` - `code` defaults to `"RATE_LIMITED"`.
118    pub fn too_many_requests(message: impl Into<String>) -> (StatusCode, Json<Self>) {
119        (
120            StatusCode::TOO_MANY_REQUESTS,
121            Json(Self::new("RATE_LIMITED", message)),
122        )
123    }
124
125    /// `503 Service Unavailable` - `code` defaults to `"SERVICE_UNAVAILABLE"`.
126    pub fn service_unavailable(message: impl Into<String>) -> (StatusCode, Json<Self>) {
127        (
128            StatusCode::SERVICE_UNAVAILABLE,
129            Json(Self::new("SERVICE_UNAVAILABLE", message)),
130        )
131    }
132
133    /// `501 Not Implemented` - `code` defaults to `"NOT_IMPLEMENTED"`.
134    pub fn not_implemented(message: impl Into<String>) -> (StatusCode, Json<Self>) {
135        (
136            StatusCode::NOT_IMPLEMENTED,
137            Json(Self::new("NOT_IMPLEMENTED", message)),
138        )
139    }
140
141    /// Attach a source error message to this error.
142    ///
143    /// Stores the source in the details field under the `"source"` key.
144    /// Can be chained with other builder methods.
145    ///
146    /// # Example
147    ///
148    /// ```rust
149    /// use axum_api_kit::ApiError;
150    ///
151    /// let err = ApiError::new("NOT_FOUND", "user not found")
152    ///     .with_source("SELECT * FROM users WHERE id = ?")
153    ///     .with_details(serde_json::json!({ "user_id": 42 }));
154    /// ```
155    pub fn with_source(mut self, source: &str) -> Self {
156        let mut details = self.details.take().unwrap_or_else(|| serde_json::json!({}));
157        if let serde_json::Value::Object(ref mut map) = details {
158            map.insert(
159                "source".to_string(),
160                serde_json::Value::String(source.to_string()),
161            );
162        }
163        self.details = Some(details);
164        self
165    }
166}
167
168/// Convert `std::io::Error` to `ApiError` with HTTP 500.
169///
170/// Maps `std::io::Error` to `ApiError::internal()` with the error message.
171/// Enables using the `?` operator in handlers:
172///
173/// ```rust,ignore
174/// async fn handler() -> impl IntoResponse {
175///     let content = std::fs::read_to_string("/data.txt")?;  // auto-converts to ApiError
176///     Ok((StatusCode::OK, content))
177/// }
178/// ```
179impl From<std::io::Error> for ApiError {
180    fn from(err: std::io::Error) -> Self {
181        Self::new("IO_ERROR", format!("IO error: {}", err))
182    }
183}
184
185/// Convert `serde_json::Error` to `ApiError` with HTTP 500.
186///
187/// Maps JSON errors to `ApiError::internal()` with the error message.
188impl From<serde_json::Error> for ApiError {
189    fn from(err: serde_json::Error) -> Self {
190        Self::new("JSON_ERROR", format!("JSON error: {}", err))
191    }
192}
193
194/// Convert `sqlx::Error` to an `ApiError` with a semantically appropriate HTTP status.
195///
196/// Requires the `sqlx` feature flag.
197///
198/// | `sqlx::Error` variant | `code` | HTTP |
199/// |---|---|---|
200/// | `RowNotFound` | `NOT_FOUND` | 404 |
201/// | `Database` (unique/FK violation) | `CONFLICT` | 409 |
202/// | `Database` (check violation) | `VALIDATION_ERROR` | 422 |
203/// | `Database` (other) | `DB_ERROR` | 500 |
204/// | `PoolTimedOut` / `PoolClosed` / `WorkerCrashed` | `SERVICE_UNAVAILABLE` | 503 |
205/// | everything else | `DB_ERROR` | 500 |
206#[cfg(feature = "sqlx")]
207impl From<sqlx::Error> for ApiError {
208    fn from(err: sqlx::Error) -> Self {
209        match err {
210            sqlx::Error::RowNotFound => Self::new("NOT_FOUND", "record not found"),
211            sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::WorkerCrashed => {
212                Self::new("SERVICE_UNAVAILABLE", "database unavailable")
213            }
214            sqlx::Error::Database(db_err) => {
215                if db_err.is_unique_violation() || db_err.is_foreign_key_violation() {
216                    Self::new("CONFLICT", db_err.message().to_string())
217                } else if db_err.is_check_violation() {
218                    Self::new("VALIDATION_ERROR", db_err.message().to_string())
219                } else {
220                    Self::new("DB_ERROR", db_err.message().to_string())
221                }
222            }
223            _ => Self::new("DB_ERROR", format!("database error: {}", err)),
224        }
225    }
226}
227
228#[cfg(feature = "validator")]
229fn collect_validation_errors(
230    prefix: Option<&str>,
231    errors: &validator::ValidationErrors,
232    out: &mut serde_json::Map<String, serde_json::Value>,
233) {
234    use validator::ValidationErrorsKind;
235
236    for (field, kind) in errors.errors() {
237        let base = if let Some(prefix) = prefix {
238            format!("{}.{}", prefix, field)
239        } else {
240            field.to_string()
241        };
242
243        match kind {
244            ValidationErrorsKind::Field(field_errors) => {
245                let items = field_errors
246                    .iter()
247                    .map(|err| {
248                        let mut obj = serde_json::Map::new();
249                        obj.insert(
250                            "code".to_string(),
251                            serde_json::Value::String(err.code.to_string()),
252                        );
253                        if let Some(message) = &err.message {
254                            obj.insert(
255                                "message".to_string(),
256                                serde_json::Value::String(message.to_string()),
257                            );
258                        }
259                        if !err.params.is_empty() {
260                            let params = match serde_json::to_value(&err.params) {
261                                Ok(v) => v,
262                                Err(_) => serde_json::Value::Null,
263                            };
264                            obj.insert("params".to_string(), params);
265                        }
266                        serde_json::Value::Object(obj)
267                    })
268                    .collect::<Vec<_>>();
269                out.insert(base, serde_json::Value::Array(items));
270            }
271            ValidationErrorsKind::Struct(nested) => {
272                collect_validation_errors(Some(&base), nested, out);
273            }
274            ValidationErrorsKind::List(items) => {
275                for (index, nested) in items {
276                    let indexed = format!("{}[{}]", base, index);
277                    collect_validation_errors(Some(&indexed), nested, out);
278                }
279            }
280        }
281    }
282}
283
284#[cfg(feature = "validator")]
285impl From<validator::ValidationErrors> for ApiError {
286    fn from(errors: validator::ValidationErrors) -> Self {
287        let mut fields = serde_json::Map::new();
288        collect_validation_errors(None, &errors, &mut fields);
289
290        Self::new("VALIDATION_ERROR", "validation failed").with_details(serde_json::json!({
291            "fields": fields
292        }))
293    }
294}
295
296/// Map an Axum [`JsonRejection`](axum::extract::rejection::JsonRejection) onto a
297/// `(StatusCode, Json<ApiError>)` with a stable machine-readable code, preserving the
298/// rejection's HTTP status so it stays in sync with Axum.
299///
300/// Shared by the `ValidatedJson` (feature `validator`) and `ApiJson` (feature `extract`)
301/// extractors. Compiled only when at least one of those features is enabled.
302#[cfg(any(feature = "validator", feature = "extract"))]
303pub(crate) fn json_rejection_to_api_error(
304    rejection: axum::extract::rejection::JsonRejection,
305) -> (StatusCode, Json<ApiError>) {
306    use axum::extract::rejection::JsonRejection;
307    let code = match &rejection {
308        JsonRejection::JsonSyntaxError(_) => "INVALID_JSON",
309        JsonRejection::JsonDataError(_) => "INVALID_BODY",
310        JsonRejection::MissingJsonContentType(_) => "UNSUPPORTED_MEDIA_TYPE",
311        _ => "BAD_REQUEST",
312    };
313    (
314        rejection.status(),
315        Json(ApiError::new(code, rejection.body_text())),
316    )
317}
318
319impl fmt::Display for ApiError {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(f, "{}: {}", self.code, self.message)
322    }
323}
324
325impl std::error::Error for ApiError {}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331
332    #[test]
333    fn new_sets_fields() {
334        let err = ApiError::new("MY_CODE", "my message");
335        assert_eq!(err.code, "MY_CODE");
336        assert_eq!(err.message, "my message");
337        assert!(err.details.is_none());
338    }
339
340    #[test]
341    fn with_details_sets_details() {
342        let err = ApiError::new("CODE", "msg").with_details(json!({ "field": "name" }));
343        assert_eq!(err.details.unwrap()["field"], "name");
344    }
345
346    #[test]
347    fn serializes_without_details() {
348        let err = ApiError::new("NOT_FOUND", "item not found");
349        let v = serde_json::to_value(&err).unwrap();
350        assert_eq!(v["code"], "NOT_FOUND");
351        assert_eq!(v["message"], "item not found");
352        assert!(v.get("details").is_none());
353    }
354
355    #[test]
356    fn serializes_with_details() {
357        let err = ApiError::new("VALIDATION_ERROR", "invalid").with_details(json!({ "x": 1 }));
358        let v = serde_json::to_value(&err).unwrap();
359        assert_eq!(v["details"]["x"], 1);
360    }
361
362    #[test]
363    fn display_formats_code_and_message() {
364        let err = ApiError::new("NOT_FOUND", "item not found");
365        assert_eq!(err.to_string(), "NOT_FOUND: item not found");
366    }
367
368    #[test]
369    fn implements_std_error() {
370        let err = ApiError::new("ERR", "something failed");
371        let _: &dyn std::error::Error = &err;
372    }
373
374    macro_rules! assert_factory {
375        ($method:expr, $expected_status:expr, $expected_code:expr) => {{
376            let (status, Json(body)) = $method;
377            assert_eq!(status, $expected_status);
378            assert_eq!(body.code, $expected_code);
379        }};
380    }
381
382    #[test]
383    fn bad_request_status_and_code() {
384        assert_factory!(
385            ApiError::bad_request("INVALID_FIELD", "bad"),
386            StatusCode::BAD_REQUEST,
387            "INVALID_FIELD"
388        );
389    }
390
391    #[test]
392    fn unauthorized_status_and_code() {
393        assert_factory!(
394            ApiError::unauthorized("please log in"),
395            StatusCode::UNAUTHORIZED,
396            "AUTH_REQUIRED"
397        );
398    }
399
400    #[test]
401    fn forbidden_status_and_code() {
402        assert_factory!(
403            ApiError::forbidden("no access"),
404            StatusCode::FORBIDDEN,
405            "FORBIDDEN"
406        );
407    }
408
409    #[test]
410    fn not_found_status_and_code() {
411        assert_factory!(
412            ApiError::not_found("missing"),
413            StatusCode::NOT_FOUND,
414            "NOT_FOUND"
415        );
416    }
417
418    #[test]
419    fn conflict_status_and_code() {
420        assert_factory!(
421            ApiError::conflict("already exists"),
422            StatusCode::CONFLICT,
423            "CONFLICT"
424        );
425    }
426
427    #[test]
428    fn unprocessable_entity_status_and_code() {
429        assert_factory!(
430            ApiError::unprocessable_entity("invalid input"),
431            StatusCode::UNPROCESSABLE_ENTITY,
432            "VALIDATION_ERROR"
433        );
434    }
435
436    #[test]
437    fn internal_status_and_code() {
438        assert_factory!(
439            ApiError::internal("oops"),
440            StatusCode::INTERNAL_SERVER_ERROR,
441            "INTERNAL_ERROR"
442        );
443    }
444
445    #[test]
446    fn db_error_status_and_code() {
447        assert_factory!(
448            ApiError::db_error(),
449            StatusCode::INTERNAL_SERVER_ERROR,
450            "DB_ERROR"
451        );
452    }
453
454    #[test]
455    fn too_many_requests_status_and_code() {
456        assert_factory!(
457            ApiError::too_many_requests("slow down"),
458            StatusCode::TOO_MANY_REQUESTS,
459            "RATE_LIMITED"
460        );
461    }
462
463    #[test]
464    fn service_unavailable_status_and_code() {
465        assert_factory!(
466            ApiError::service_unavailable("down for maintenance"),
467            StatusCode::SERVICE_UNAVAILABLE,
468            "SERVICE_UNAVAILABLE"
469        );
470    }
471
472    #[test]
473    fn not_implemented_status_and_code() {
474        assert_factory!(
475            ApiError::not_implemented("coming soon"),
476            StatusCode::NOT_IMPLEMENTED,
477            "NOT_IMPLEMENTED"
478        );
479    }
480
481    #[test]
482    fn with_source_adds_source_to_details() {
483        let err = ApiError::new("NOT_FOUND", "missing").with_source("db query");
484        let v = serde_json::to_value(&err).unwrap();
485        assert_eq!(v["details"]["source"], "db query");
486        assert_eq!(v["code"], "NOT_FOUND");
487    }
488
489    #[test]
490    fn with_source_and_with_details_both_present() {
491        let err = ApiError::new("ERROR", "msg")
492            .with_details(json!({ "user_id": 123 }))
493            .with_source("from somewhere");
494        let v = serde_json::to_value(&err).unwrap();
495        assert_eq!(v["details"]["source"], "from somewhere");
496        assert_eq!(v["details"]["user_id"], 123);
497    }
498
499    #[test]
500    fn from_io_error_creates_io_error_code() {
501        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
502        let api_err: ApiError = io_err.into();
503        assert_eq!(api_err.code, "IO_ERROR");
504        assert!(api_err.message.contains("IO error"));
505    }
506
507    #[test]
508    fn from_serde_json_error_creates_json_error_code() {
509        let json_str = "{ invalid json }";
510        let json_err: Result<serde_json::Value, _> = serde_json::from_str(json_str);
511        let api_err: ApiError = json_err.unwrap_err().into();
512        assert_eq!(api_err.code, "JSON_ERROR");
513        assert!(api_err.message.contains("JSON error"));
514    }
515
516    #[test]
517    fn io_error_conversion_captures_kind() {
518        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
519        let api_err: ApiError = io_err.into();
520        assert!(api_err.message.contains("permission denied"));
521    }
522
523    #[cfg(feature = "validator")]
524    #[test]
525    fn from_validation_errors_single_field() {
526        use std::borrow::Cow;
527        use validator::{ValidationError, ValidationErrors};
528
529        let mut errors = ValidationErrors::new();
530        let mut email = ValidationError::new("email");
531        email.message = Some(Cow::Borrowed("invalid email"));
532        errors.add("email", email);
533
534        let api_err: ApiError = errors.into();
535        let v = serde_json::to_value(api_err).unwrap();
536
537        assert_eq!(v["code"], "VALIDATION_ERROR");
538        assert_eq!(v["message"], "validation failed");
539        assert_eq!(v["details"]["fields"]["email"][0]["code"], "email");
540        assert_eq!(
541            v["details"]["fields"]["email"][0]["message"],
542            "invalid email"
543        );
544    }
545
546    #[cfg(feature = "validator")]
547    #[test]
548    fn from_validation_errors_multiple_fields_with_params() {
549        use std::borrow::Cow;
550        use validator::{ValidationError, ValidationErrors};
551
552        let mut errors = ValidationErrors::new();
553
554        let mut username = ValidationError::new("length");
555        username.message = Some(Cow::Borrowed("username too short"));
556        username.add_param(Cow::Borrowed("min"), &3);
557        errors.add("username", username);
558
559        let mut age = ValidationError::new("range");
560        age.add_param(Cow::Borrowed("min"), &18);
561        errors.add("age", age);
562
563        let api_err: ApiError = errors.into();
564        let v = serde_json::to_value(api_err).unwrap();
565
566        assert_eq!(v["details"]["fields"]["username"][0]["code"], "length");
567        assert_eq!(v["details"]["fields"]["username"][0]["params"]["min"], 3);
568        assert_eq!(v["details"]["fields"]["age"][0]["code"], "range");
569        assert_eq!(v["details"]["fields"]["age"][0]["params"]["min"], 18);
570    }
571
572    #[cfg(feature = "sqlx")]
573    #[test]
574    fn sqlx_row_not_found_maps_to_not_found() {
575        let api_err: ApiError = sqlx::Error::RowNotFound.into();
576        assert_eq!(api_err.code, "NOT_FOUND");
577        assert_eq!(api_err.message, "record not found");
578    }
579
580    #[cfg(feature = "sqlx")]
581    #[test]
582    fn sqlx_pool_timed_out_maps_to_service_unavailable() {
583        let api_err: ApiError = sqlx::Error::PoolTimedOut.into();
584        assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
585    }
586
587    #[cfg(feature = "sqlx")]
588    #[test]
589    fn sqlx_pool_closed_maps_to_service_unavailable() {
590        let api_err: ApiError = sqlx::Error::PoolClosed.into();
591        assert_eq!(api_err.code, "SERVICE_UNAVAILABLE");
592    }
593
594    #[cfg(feature = "sqlx")]
595    #[test]
596    fn sqlx_unknown_variant_maps_to_db_error() {
597        // Protocol is a non-pool, non-database variant that hits the catch-all arm.
598        let api_err: ApiError = sqlx::Error::Protocol("unexpected packet".into()).into();
599        assert_eq!(api_err.code, "DB_ERROR");
600        assert!(api_err.message.contains("database error"));
601    }
602}