Skip to main content

axum_api_kit/
error.rs

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