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