Skip to main content

api_bones/
error.rs

1//! Standard API error types for all api-bones services.
2//!
3//! All services serialize errors into [`ApiError`] before sending an HTTP
4//! response. The wire format conforms to
5//! [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457):
6//!
7//! ```json
8//! {
9//!   "type": "urn:api-bones:error:resource-not-found",
10//!   "title": "Resource Not Found",
11//!   "status": 404,
12//!   "detail": "Booking 123 not found",
13//!   "instance": "urn:uuid:01234567-89ab-cdef-0123-456789abcdef"
14//! }
15//! ```
16//!
17//! Content-Type: `application/problem+json`
18//!
19//! # `no_std` + `alloc` limitations
20//!
21//! [`ErrorTypeMode`] is available under the `alloc` feature (its fields use
22//! `String`), but the global accessors [`error_type_mode`] and
23//! [`set_error_type_mode`] require the `std` feature because they rely on
24//! [`std::sync::RwLock`] and [`std::env::var`].
25//!
26//! In a `no_std + alloc` environment you can still construct an
27//! [`ErrorTypeMode`] value and call [`ErrorTypeMode::render`] directly, but
28//! the automatic environment-variable resolution is unavailable.
29
30#[cfg(all(not(feature = "std"), feature = "alloc", feature = "serde"))]
31use alloc::collections::BTreeMap;
32#[cfg(all(not(feature = "std"), feature = "alloc"))]
33use alloc::{borrow::ToOwned, format, string::String, string::ToString, sync::Arc, vec::Vec};
34use core::fmt;
35#[cfg(all(feature = "std", not(feature = "serde")))]
36use std::sync::Arc;
37#[cfg(all(feature = "std", feature = "serde"))]
38use std::{collections::BTreeMap, sync::Arc};
39
40#[cfg(feature = "serde")]
41use serde::{Deserialize, Serialize};
42
43// ---------------------------------------------------------------------------
44// Error code
45// ---------------------------------------------------------------------------
46
47/// Machine-readable error code included in every API error response.
48///
49/// Serializes as a URN per [RFC 9457 §3.1.1](https://www.rfc-editor.org/rfc/rfc9457#section-3.1.1),
50/// which requires the `type` member to be a URI reference.
51/// Format: `urn:api-bones:error:<slug>` (e.g. `urn:api-bones:error:resource-not-found`).
52///
53/// # Examples
54///
55/// ```rust
56/// use api_bones::error::ErrorCode;
57///
58/// let code = ErrorCode::ResourceNotFound;
59/// assert_eq!(code.status_code(), 404);
60/// assert_eq!(code.title(), "Resource Not Found");
61/// assert_eq!(code.urn_slug(), "resource-not-found");
62/// ```
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
65#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
66#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
67pub enum ErrorCode {
68    // 400
69    BadRequest,
70    ValidationFailed,
71    // 401
72    Unauthorized,
73    InvalidCredentials,
74    TokenExpired,
75    TokenInvalid,
76    // 403
77    Forbidden,
78    InsufficientPermissions,
79    OrgOutsideSubtree,
80    AncestorRequired,
81    CrossSubtreeAccess,
82    // 404
83    ResourceNotFound,
84    // 405
85    MethodNotAllowed,
86    // 406
87    NotAcceptable,
88    // 408
89    RequestTimeout,
90    // 409
91    Conflict,
92    ResourceAlreadyExists,
93    // 410
94    Gone,
95    // 412
96    PreconditionFailed,
97    // 413
98    PayloadTooLarge,
99    // 415
100    UnsupportedMediaType,
101    // 422
102    UnprocessableEntity,
103    // 428
104    PreconditionRequired,
105    // 429
106    RateLimited,
107    // 431
108    RequestHeaderFieldsTooLarge,
109    // 500
110    InternalServerError,
111    // 501
112    NotImplemented,
113    // 502
114    BadGateway,
115    // 503
116    ServiceUnavailable,
117    // 504
118    GatewayTimeout,
119}
120
121/// How the RFC 9457 `type` field is rendered for [`ErrorCode`].
122///
123/// RFC 9457 §3.1.1 requires `type` to be a URI reference and encourages using
124/// resolvable URLs so consumers can look up documentation. This enum lets you
125/// choose the format that fits your deployment.
126///
127/// Requires `std` or `alloc` (fields contain `String`).
128///
129/// # `no_std` note
130///
131/// This type is available with `alloc` alone, but the global accessors
132/// [`error_type_mode`] and [`set_error_type_mode`] require the `std` feature
133/// (`RwLock` + env-var access). In a `no_std + alloc` context, construct the
134/// variant you need and call [`ErrorTypeMode::render`] directly.
135///
136/// # Configuration
137///
138/// Set the mode once at startup via [`set_error_type_mode`], or let it
139/// auto-resolve from environment variables (see [`error_type_mode`]).
140///
141/// ## URL mode (recommended)
142///
143/// Produces `{base_url}/{slug}`, e.g.:
144/// `https://docs.myapp.com/errors/resource-not-found`
145///
146/// Set via env: `SHARED_TYPES_ERROR_TYPE_BASE_URL=https://docs.myapp.com/errors`
147///
148/// ## URN mode (fallback)
149///
150/// Produces `urn:{namespace}:error:{slug}`, e.g.:
151/// `urn:myapp:error:resource-not-found`
152///
153/// Set via env: `SHARED_TYPES_URN_NAMESPACE=myapp`
154///
155/// # Examples
156///
157/// ```rust
158/// use api_bones::error::ErrorTypeMode;
159///
160/// let url_mode = ErrorTypeMode::Url { base_url: "https://docs.example.com/errors".into() };
161/// assert_eq!(url_mode.render("not-found"), "https://docs.example.com/errors/not-found");
162///
163/// let urn_mode = ErrorTypeMode::Urn { namespace: "myapp".into() };
164/// assert_eq!(urn_mode.render("not-found"), "urn:myapp:error:not-found");
165/// ```
166#[cfg(any(feature = "std", feature = "alloc"))]
167#[derive(Debug, Clone, PartialEq, Eq)]
168#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
169#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
170#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
171pub enum ErrorTypeMode {
172    /// Generate a resolvable URL per RFC 9457 §3.1.1 (recommended).
173    /// Format: `{base_url}/{slug}` — trailing slash in `base_url` is trimmed automatically.
174    Url {
175        /// Base URL for error documentation, e.g. `https://docs.myapp.com/errors`.
176        base_url: String,
177    },
178    /// Generate a URN per RFC 9457 §3.1.1 + RFC 8141.
179    /// Format: `urn:{namespace}:error:{slug}`.
180    Urn {
181        /// URN namespace, e.g. `"myapp"`.
182        namespace: String,
183    },
184}
185
186#[cfg(any(feature = "std", feature = "alloc"))]
187impl ErrorTypeMode {
188    /// Render the full `type` URI for a given error slug.
189    ///
190    /// # Examples
191    ///
192    /// ```rust
193    /// use api_bones::error::ErrorTypeMode;
194    ///
195    /// let mode = ErrorTypeMode::Url { base_url: "https://example.com/errors/".into() };
196    /// assert_eq!(mode.render("bad-request"), "https://example.com/errors/bad-request");
197    ///
198    /// let mode = ErrorTypeMode::Urn { namespace: "acme".into() };
199    /// assert_eq!(mode.render("bad-request"), "urn:acme:error:bad-request");
200    /// ```
201    #[must_use]
202    pub fn render(&self, slug: &str) -> String {
203        match self {
204            Self::Url { base_url } => format!("{}/{slug}", base_url.trim_end_matches('/')),
205            Self::Urn { namespace } => format!("urn:{namespace}:error:{slug}"),
206        }
207    }
208}
209
210/// Returns the active [`ErrorTypeMode`].
211///
212/// Resolution order (first wins):
213/// 1. Value set via [`set_error_type_mode`] (programmatic, highest priority)
214/// 2. Compile-time `SHARED_TYPES_ERROR_TYPE_BASE_URL` → [`ErrorTypeMode::Url`]
215/// 3. Runtime `SHARED_TYPES_ERROR_TYPE_BASE_URL` → [`ErrorTypeMode::Url`]
216/// 4. Compile-time `SHARED_TYPES_URN_NAMESPACE` → [`ErrorTypeMode::Urn`]
217/// 5. Runtime `SHARED_TYPES_URN_NAMESPACE` → [`ErrorTypeMode::Urn`]
218/// 6. Default: `ErrorTypeMode::Urn { namespace: "api-bones".into() }`
219///
220/// Requires the `std` feature (`RwLock` + environment variable access).
221#[cfg(feature = "std")]
222static ERROR_TYPE_MODE: std::sync::RwLock<Option<ErrorTypeMode>> = std::sync::RwLock::new(None);
223
224/// Resolve the mode from environment variables and compile-time settings.
225#[cfg(feature = "std")]
226fn resolve_error_type_mode() -> ErrorTypeMode {
227    // 1. Compile-time base URL → URL mode (never set in CI/test; excluded from
228    //    coverage instrumentation to avoid false "missed function" reports)
229    #[cfg(not(coverage))]
230    if let Some(url) = option_env!("SHARED_TYPES_ERROR_TYPE_BASE_URL")
231        && !url.is_empty()
232    {
233        return ErrorTypeMode::Url {
234            base_url: url.to_owned(),
235        };
236    }
237    // 2. Runtime base URL → URL mode
238    if let Ok(url) = std::env::var("SHARED_TYPES_ERROR_TYPE_BASE_URL")
239        && !url.is_empty()
240    {
241        return ErrorTypeMode::Url { base_url: url };
242    }
243    // 3. Compile-time URN namespace → URN mode (same rationale as #1)
244    #[cfg(not(coverage))]
245    if let Some(ns) = option_env!("SHARED_TYPES_URN_NAMESPACE")
246        && !ns.is_empty()
247    {
248        return ErrorTypeMode::Urn {
249            namespace: ns.to_owned(),
250        };
251    }
252    // 4. Runtime URN namespace → URN mode
253    if let Ok(ns) = std::env::var("SHARED_TYPES_URN_NAMESPACE")
254        && !ns.is_empty()
255    {
256        return ErrorTypeMode::Urn { namespace: ns };
257    }
258    // 5. Default
259    ErrorTypeMode::Urn {
260        namespace: "api-bones".to_owned(),
261    }
262}
263
264/// Returns the active [`ErrorTypeMode`].
265///
266/// Resolution order:
267///
268/// 1. Value set via [`set_error_type_mode`] (programmatic, highest priority)
269/// 2. Compile-time `SHARED_TYPES_ERROR_TYPE_BASE_URL` → [`ErrorTypeMode::Url`]
270/// 3. Runtime `SHARED_TYPES_ERROR_TYPE_BASE_URL` → [`ErrorTypeMode::Url`]
271/// 4. Compile-time `SHARED_TYPES_URN_NAMESPACE` → [`ErrorTypeMode::Urn`]
272/// 5. Runtime `SHARED_TYPES_URN_NAMESPACE` → [`ErrorTypeMode::Urn`]
273/// 6. Default: `ErrorTypeMode::Urn { namespace: "api-bones".into() }`
274///
275/// Requires the `std` feature.
276///
277/// # Examples
278///
279/// ```rust
280/// use api_bones::error::{error_type_mode, set_error_type_mode, ErrorTypeMode};
281///
282/// set_error_type_mode(ErrorTypeMode::Urn { namespace: "demo".into() });
283/// let mode = error_type_mode();
284/// assert_eq!(mode, ErrorTypeMode::Urn { namespace: "demo".into() });
285/// ```
286#[cfg(feature = "std")]
287#[must_use]
288pub fn error_type_mode() -> ErrorTypeMode {
289    {
290        let guard = ERROR_TYPE_MODE
291            .read()
292            .expect("error type mode lock poisoned");
293        if let Some(mode) = guard.as_ref() {
294            return mode.clone();
295        }
296    }
297    // Not yet initialised — resolve and store.
298    let mut guard = ERROR_TYPE_MODE
299        .write()
300        .expect("error type mode lock poisoned");
301    // Double-check after acquiring write lock.
302    if let Some(mode) = guard.as_ref() {
303        return mode.clone();
304    }
305    let mode = resolve_error_type_mode();
306    *guard = Some(mode.clone());
307    mode
308}
309
310/// Override the error type mode programmatically (call once at application startup).
311///
312/// Unlike the previous `OnceLock`-based implementation, this will overwrite any
313/// previously set or auto-resolved mode.
314///
315/// Requires the `std` feature.
316///
317/// # Example
318/// ```rust
319/// use api_bones::error::{set_error_type_mode, ErrorTypeMode};
320///
321/// set_error_type_mode(ErrorTypeMode::Url {
322///     base_url: "https://docs.myapp.com/errors".into(),
323/// });
324/// ```
325#[cfg(feature = "std")]
326pub fn set_error_type_mode(mode: ErrorTypeMode) {
327    let mut guard = ERROR_TYPE_MODE
328        .write()
329        .expect("error type mode lock poisoned");
330    *guard = Some(mode);
331}
332
333/// Reset the error type mode to uninitialized so the next call to
334/// [`error_type_mode`] re-resolves from environment variables.
335///
336/// Only available in test builds.
337#[cfg(all(test, feature = "std"))]
338pub(crate) fn reset_error_type_mode() {
339    let mut guard = ERROR_TYPE_MODE
340        .write()
341        .expect("error type mode lock poisoned");
342    *guard = None;
343}
344
345/// Returns the active URN namespace (convenience wrapper around [`error_type_mode`]).
346/// Only meaningful when in [`ErrorTypeMode::Urn`] mode.
347///
348/// Requires the `std` feature.
349///
350/// # Examples
351///
352/// ```rust
353/// use api_bones::error::{urn_namespace, set_error_type_mode, ErrorTypeMode};
354///
355/// set_error_type_mode(ErrorTypeMode::Urn { namespace: "myapp".into() });
356/// assert_eq!(urn_namespace(), "myapp");
357/// ```
358#[cfg(feature = "std")]
359#[must_use]
360pub fn urn_namespace() -> String {
361    match error_type_mode() {
362        ErrorTypeMode::Urn { namespace } => namespace,
363        ErrorTypeMode::Url { .. } => "api-bones".to_owned(),
364    }
365}
366
367impl ErrorCode {
368    /// HTTP status code for this error code.
369    ///
370    /// # Examples
371    ///
372    /// ```rust
373    /// use api_bones::error::ErrorCode;
374    ///
375    /// assert_eq!(ErrorCode::BadRequest.status_code(), 400);
376    /// assert_eq!(ErrorCode::Unauthorized.status_code(), 401);
377    /// assert_eq!(ErrorCode::InternalServerError.status_code(), 500);
378    /// ```
379    #[must_use]
380    pub fn status_code(&self) -> u16 {
381        match self {
382            Self::BadRequest | Self::ValidationFailed => 400,
383            Self::Unauthorized
384            | Self::InvalidCredentials
385            | Self::TokenExpired
386            | Self::TokenInvalid => 401,
387            Self::Forbidden
388            | Self::InsufficientPermissions
389            | Self::OrgOutsideSubtree
390            | Self::AncestorRequired
391            | Self::CrossSubtreeAccess => 403,
392            Self::ResourceNotFound => 404,
393            Self::MethodNotAllowed => 405,
394            Self::NotAcceptable => 406,
395            Self::RequestTimeout => 408,
396            Self::Conflict | Self::ResourceAlreadyExists => 409,
397            Self::Gone => 410,
398            Self::PreconditionFailed => 412,
399            Self::PayloadTooLarge => 413,
400            Self::UnsupportedMediaType => 415,
401            Self::UnprocessableEntity => 422,
402            Self::PreconditionRequired => 428,
403            Self::RateLimited => 429,
404            Self::RequestHeaderFieldsTooLarge => 431,
405            Self::InternalServerError => 500,
406            Self::NotImplemented => 501,
407            Self::BadGateway => 502,
408            Self::ServiceUnavailable => 503,
409            Self::GatewayTimeout => 504,
410        }
411    }
412
413    /// Human-friendly title for this error code (RFC 9457 `title` field).
414    ///
415    /// # Examples
416    ///
417    /// ```rust
418    /// use api_bones::error::ErrorCode;
419    ///
420    /// assert_eq!(ErrorCode::ResourceNotFound.title(), "Resource Not Found");
421    /// assert_eq!(ErrorCode::BadRequest.title(), "Bad Request");
422    /// ```
423    #[must_use]
424    pub fn title(&self) -> &'static str {
425        match self {
426            Self::BadRequest => "Bad Request",
427            Self::ValidationFailed => "Validation Failed",
428            Self::Unauthorized => "Unauthorized",
429            Self::InvalidCredentials => "Invalid Credentials",
430            Self::TokenExpired => "Token Expired",
431            Self::TokenInvalid => "Token Invalid",
432            Self::Forbidden => "Forbidden",
433            Self::InsufficientPermissions => "Insufficient Permissions",
434            Self::OrgOutsideSubtree => "Org Outside Subtree",
435            Self::AncestorRequired => "Ancestor Required",
436            Self::CrossSubtreeAccess => "Cross Subtree Access",
437            Self::ResourceNotFound => "Resource Not Found",
438            Self::MethodNotAllowed => "Method Not Allowed",
439            Self::NotAcceptable => "Not Acceptable",
440            Self::RequestTimeout => "Request Timeout",
441            Self::Conflict => "Conflict",
442            Self::ResourceAlreadyExists => "Resource Already Exists",
443            Self::Gone => "Gone",
444            Self::PreconditionFailed => "Precondition Failed",
445            Self::PayloadTooLarge => "Payload Too Large",
446            Self::UnsupportedMediaType => "Unsupported Media Type",
447            Self::UnprocessableEntity => "Unprocessable Entity",
448            Self::PreconditionRequired => "Precondition Required",
449            Self::RateLimited => "Rate Limited",
450            Self::RequestHeaderFieldsTooLarge => "Request Header Fields Too Large",
451            Self::InternalServerError => "Internal Server Error",
452            Self::NotImplemented => "Not Implemented",
453            Self::BadGateway => "Bad Gateway",
454            Self::ServiceUnavailable => "Service Unavailable",
455            Self::GatewayTimeout => "Gateway Timeout",
456        }
457    }
458
459    /// The URN slug for this error code (the part after `urn:api-bones:error:`).
460    ///
461    /// # Examples
462    ///
463    /// ```rust
464    /// use api_bones::error::ErrorCode;
465    ///
466    /// assert_eq!(ErrorCode::ResourceNotFound.urn_slug(), "resource-not-found");
467    /// assert_eq!(ErrorCode::ValidationFailed.urn_slug(), "validation-failed");
468    /// ```
469    #[must_use]
470    pub fn urn_slug(&self) -> &'static str {
471        match self {
472            Self::BadRequest => "bad-request",
473            Self::ValidationFailed => "validation-failed",
474            Self::Unauthorized => "unauthorized",
475            Self::InvalidCredentials => "invalid-credentials",
476            Self::TokenExpired => "token-expired",
477            Self::TokenInvalid => "token-invalid",
478            Self::Forbidden => "forbidden",
479            Self::InsufficientPermissions => "insufficient-permissions",
480            Self::OrgOutsideSubtree => "org-outside-subtree",
481            Self::AncestorRequired => "ancestor-required",
482            Self::CrossSubtreeAccess => "cross-subtree-access",
483            Self::ResourceNotFound => "resource-not-found",
484            Self::MethodNotAllowed => "method-not-allowed",
485            Self::NotAcceptable => "not-acceptable",
486            Self::RequestTimeout => "request-timeout",
487            Self::Conflict => "conflict",
488            Self::ResourceAlreadyExists => "resource-already-exists",
489            Self::Gone => "gone",
490            Self::PreconditionFailed => "precondition-failed",
491            Self::PayloadTooLarge => "payload-too-large",
492            Self::UnsupportedMediaType => "unsupported-media-type",
493            Self::UnprocessableEntity => "unprocessable-entity",
494            Self::PreconditionRequired => "precondition-required",
495            Self::RateLimited => "rate-limited",
496            Self::RequestHeaderFieldsTooLarge => "request-header-fields-too-large",
497            Self::InternalServerError => "internal-server-error",
498            Self::NotImplemented => "not-implemented",
499            Self::BadGateway => "bad-gateway",
500            Self::ServiceUnavailable => "service-unavailable",
501            Self::GatewayTimeout => "gateway-timeout",
502        }
503    }
504
505    /// Full type URI for this error code per RFC 9457 §3.1.1.
506    ///
507    /// The format depends on the active [`ErrorTypeMode`] (see [`error_type_mode`]):
508    /// - URL mode: `https://docs.myapp.com/errors/resource-not-found`
509    /// - URN mode: `urn:myapp:error:resource-not-found`
510    ///
511    /// Requires the `std` feature (dynamic namespace resolution via [`error_type_mode`]).
512    ///
513    /// # Examples
514    ///
515    /// ```rust
516    /// use api_bones::error::{ErrorCode, set_error_type_mode, ErrorTypeMode};
517    ///
518    /// set_error_type_mode(ErrorTypeMode::Urn { namespace: "test".into() });
519    /// assert_eq!(ErrorCode::ResourceNotFound.urn(), "urn:test:error:resource-not-found");
520    /// ```
521    #[cfg(feature = "std")]
522    #[must_use]
523    pub fn urn(&self) -> String {
524        error_type_mode().render(self.urn_slug())
525    }
526
527    /// Parse an `ErrorCode` from a type URI string (URL or URN format).
528    ///
529    /// Requires the `std` feature (dynamic namespace resolution via [`error_type_mode`]).
530    ///
531    /// # Examples
532    ///
533    /// ```rust
534    /// use api_bones::error::{ErrorCode, set_error_type_mode, ErrorTypeMode};
535    ///
536    /// set_error_type_mode(ErrorTypeMode::Urn { namespace: "test".into() });
537    /// let code = ErrorCode::ResourceNotFound;
538    /// let uri = code.urn();
539    /// assert_eq!(ErrorCode::from_type_uri(&uri), Some(ErrorCode::ResourceNotFound));
540    /// ```
541    #[cfg(feature = "std")]
542    #[must_use]
543    pub fn from_type_uri(s: &str) -> Option<Self> {
544        // Try to extract slug from the active mode's format first, then fall back
545        let slug = match error_type_mode() {
546            ErrorTypeMode::Url { base_url } => {
547                let prefix = format!("{}/", base_url.trim_end_matches('/'));
548                s.strip_prefix(prefix.as_str()).or_else(|| {
549                    // Also accept URN format as fallback
550                    let urn_prefix = format!("urn:{}:error:", urn_namespace());
551                    s.strip_prefix(urn_prefix.as_str())
552                })?
553            }
554            ErrorTypeMode::Urn { namespace } => {
555                let prefix = format!("urn:{namespace}:error:");
556                s.strip_prefix(prefix.as_str())?
557            }
558        };
559        Some(match slug {
560            "bad-request" => Self::BadRequest,
561            "validation-failed" => Self::ValidationFailed,
562            "unauthorized" => Self::Unauthorized,
563            "invalid-credentials" => Self::InvalidCredentials,
564            "token-expired" => Self::TokenExpired,
565            "token-invalid" => Self::TokenInvalid,
566            "forbidden" => Self::Forbidden,
567            "insufficient-permissions" => Self::InsufficientPermissions,
568            "org-outside-subtree" => Self::OrgOutsideSubtree,
569            "ancestor-required" => Self::AncestorRequired,
570            "cross-subtree-access" => Self::CrossSubtreeAccess,
571            "resource-not-found" => Self::ResourceNotFound,
572            "method-not-allowed" => Self::MethodNotAllowed,
573            "not-acceptable" => Self::NotAcceptable,
574            "request-timeout" => Self::RequestTimeout,
575            "conflict" => Self::Conflict,
576            "resource-already-exists" => Self::ResourceAlreadyExists,
577            "gone" => Self::Gone,
578            "precondition-failed" => Self::PreconditionFailed,
579            "payload-too-large" => Self::PayloadTooLarge,
580            "unsupported-media-type" => Self::UnsupportedMediaType,
581            "unprocessable-entity" => Self::UnprocessableEntity,
582            "precondition-required" => Self::PreconditionRequired,
583            "rate-limited" => Self::RateLimited,
584            "request-header-fields-too-large" => Self::RequestHeaderFieldsTooLarge,
585            "internal-server-error" => Self::InternalServerError,
586            "not-implemented" => Self::NotImplemented,
587            "bad-gateway" => Self::BadGateway,
588            "service-unavailable" => Self::ServiceUnavailable,
589            "gateway-timeout" => Self::GatewayTimeout,
590            _ => return None,
591        })
592    }
593}
594
595/// In `std` mode the display resolves through the dynamic [`error_type_mode`].
596#[cfg(feature = "std")]
597impl fmt::Display for ErrorCode {
598    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599        f.write_str(&self.urn())
600    }
601}
602
603/// In `no_std` mode the display falls back to a fixed `urn:api-bones:error:<slug>` format.
604#[cfg(not(feature = "std"))]
605impl fmt::Display for ErrorCode {
606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607        write!(f, "urn:api-bones:error:{}", self.urn_slug())
608    }
609}
610
611#[cfg(all(feature = "serde", feature = "std"))]
612impl Serialize for ErrorCode {
613    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
614        s.serialize_str(&self.urn())
615    }
616}
617
618#[cfg(all(feature = "serde", feature = "std"))]
619impl<'de> Deserialize<'de> for ErrorCode {
620    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
621        let s = String::deserialize(d)?;
622        Self::from_type_uri(&s)
623            .ok_or_else(|| serde::de::Error::custom(format!("unknown error type URI: {s}")))
624    }
625}
626
627#[cfg(feature = "utoipa")]
628impl utoipa::PartialSchema for ErrorCode {
629    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
630        use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
631        utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
632            ObjectBuilder::new()
633                .schema_type(SchemaType::new(Type::String))
634                .examples(["urn:api-bones:error:resource-not-found"])
635                .build(),
636        ))
637    }
638}
639
640#[cfg(feature = "utoipa")]
641impl utoipa::ToSchema for ErrorCode {
642    fn name() -> std::borrow::Cow<'static, str> {
643        std::borrow::Cow::Borrowed("ErrorCode")
644    }
645}
646
647// ---------------------------------------------------------------------------
648// TryFrom<u16> / TryFrom<http::StatusCode> for ErrorCode
649// ---------------------------------------------------------------------------
650
651/// Attempt to convert an HTTP status code (as `u16`) to its canonical
652/// [`ErrorCode`] variant.
653///
654/// Only 4xx and 5xx codes that have a direct mapping return `Ok`; all other
655/// codes (1xx, 2xx, 3xx, or unmapped 4xx/5xx) return `Err(())`.
656///
657/// # Examples
658///
659/// ```rust
660/// use api_bones::error::ErrorCode;
661///
662/// assert_eq!(ErrorCode::try_from(404_u16), Ok(ErrorCode::ResourceNotFound));
663/// assert_eq!(ErrorCode::try_from(500_u16), Ok(ErrorCode::InternalServerError));
664/// assert!(ErrorCode::try_from(200_u16).is_err());
665/// assert!(ErrorCode::try_from(301_u16).is_err());
666/// ```
667impl TryFrom<u16> for ErrorCode {
668    type Error = ();
669
670    fn try_from(status: u16) -> Result<Self, Self::Error> {
671        match status {
672            400 => Ok(Self::BadRequest),
673            401 => Ok(Self::Unauthorized),
674            403 => Ok(Self::Forbidden),
675            404 => Ok(Self::ResourceNotFound),
676            405 => Ok(Self::MethodNotAllowed),
677            406 => Ok(Self::NotAcceptable),
678            408 => Ok(Self::RequestTimeout),
679            409 => Ok(Self::Conflict),
680            410 => Ok(Self::Gone),
681            412 => Ok(Self::PreconditionFailed),
682            413 => Ok(Self::PayloadTooLarge),
683            415 => Ok(Self::UnsupportedMediaType),
684            422 => Ok(Self::UnprocessableEntity),
685            428 => Ok(Self::PreconditionRequired),
686            429 => Ok(Self::RateLimited),
687            431 => Ok(Self::RequestHeaderFieldsTooLarge),
688            500 => Ok(Self::InternalServerError),
689            501 => Ok(Self::NotImplemented),
690            502 => Ok(Self::BadGateway),
691            503 => Ok(Self::ServiceUnavailable),
692            504 => Ok(Self::GatewayTimeout),
693            _ => Err(()),
694        }
695    }
696}
697
698/// Attempt to convert an [`http::StatusCode`] to its canonical [`ErrorCode`]
699/// variant.
700///
701/// Delegates to [`TryFrom<u16>`] for [`ErrorCode`]; see that impl for the
702/// full mapping. Non-error status codes (1xx, 2xx, 3xx) and unmapped 4xx/5xx
703/// codes return `Err(())`.
704///
705/// Requires the `http` feature.
706///
707/// # Examples
708///
709/// ```rust
710/// use api_bones::error::ErrorCode;
711/// use http::StatusCode;
712///
713/// assert_eq!(
714///     ErrorCode::try_from(StatusCode::NOT_FOUND),
715///     Ok(ErrorCode::ResourceNotFound),
716/// );
717/// assert!(ErrorCode::try_from(StatusCode::OK).is_err());
718/// ```
719#[cfg(feature = "http")]
720impl TryFrom<http::StatusCode> for ErrorCode {
721    type Error = ();
722
723    fn try_from(status: http::StatusCode) -> Result<Self, Self::Error> {
724        Self::try_from(status.as_u16())
725    }
726}
727
728// ---------------------------------------------------------------------------
729// Validation error
730// ---------------------------------------------------------------------------
731
732/// A single field-level validation error, used in [`ApiError::errors`].
733///
734/// Carried as a documented extension member alongside the standard
735/// [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) fields.
736///
737/// Requires `std` or `alloc` (fields contain `String`).
738///
739/// # Examples
740///
741/// ```rust
742/// use api_bones::error::ValidationError;
743///
744/// let err = ValidationError {
745///     field: "/email".into(),
746///     message: "must be a valid email".into(),
747///     rule: Some("email".into()),
748/// };
749/// assert_eq!(err.to_string(), "/email: must be a valid email (rule: email)");
750///
751/// let err2 = ValidationError {
752///     field: "/name".into(),
753///     message: "is required".into(),
754///     rule: None,
755/// };
756/// assert_eq!(err2.to_string(), "/name: is required");
757/// ```
758#[cfg(any(feature = "std", feature = "alloc"))]
759#[derive(Debug, Clone, PartialEq, Eq)]
760#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
761#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
763#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
764#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
765pub struct ValidationError {
766    /// JSON Pointer to the offending field (e.g. `"/email"`).
767    pub field: String,
768    /// Human-readable description of what went wrong.
769    pub message: String,
770    /// Optional machine-readable rule that failed (e.g. `"min_length"`).
771    #[cfg_attr(
772        feature = "serde",
773        serde(default, skip_serializing_if = "Option::is_none")
774    )]
775    pub rule: Option<String>,
776}
777
778#[cfg(any(feature = "std", feature = "alloc"))]
779impl fmt::Display for ValidationError {
780    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
781        match &self.rule {
782            Some(rule) => write!(f, "{}: {} (rule: {})", self.field, self.message, rule),
783            None => write!(f, "{}: {}", self.field, self.message),
784        }
785    }
786}
787
788#[cfg(any(feature = "std", feature = "alloc"))]
789impl core::error::Error for ValidationError {}
790
791// ---------------------------------------------------------------------------
792// HttpError trait — blanket From<E: HttpError> for ApiError
793// ---------------------------------------------------------------------------
794
795/// Implement this trait on domain error types to get a blanket
796/// [`From`] implementation into [`ApiError`] for free.
797///
798/// # Examples
799///
800/// ```rust
801/// use api_bones::error::{ApiError, ErrorCode, HttpError};
802///
803/// #[derive(Debug)]
804/// struct BookingNotFound(u64);
805///
806/// impl HttpError for BookingNotFound {
807///     fn status_code(&self) -> u16 { 404 }
808///     fn error_code(&self) -> ErrorCode { ErrorCode::ResourceNotFound }
809///     fn detail(&self) -> String { format!("Booking {} not found", self.0) }
810/// }
811///
812/// let err: ApiError = BookingNotFound(42).into();
813/// assert_eq!(err.status, 404);
814/// assert_eq!(err.detail, "Booking 42 not found");
815/// ```
816#[cfg(any(feature = "std", feature = "alloc"))]
817pub trait HttpError: core::fmt::Debug {
818    /// HTTP status code (e.g. `404`).
819    fn status_code(&self) -> u16;
820    /// Machine-readable [`ErrorCode`] for this error.
821    fn error_code(&self) -> ErrorCode;
822    /// Human-readable detail string (RFC 9457 §3.1.4 `detail`).
823    fn detail(&self) -> String;
824}
825
826/// Blanket conversion: any `HttpError` implementor becomes an [`ApiError`].
827///
828/// This is a blanket impl over a sealed trait parameter so it does not
829/// conflict with other `From` impls on `ApiError`.
830#[cfg(any(feature = "std", feature = "alloc"))]
831impl<E: HttpError> From<E> for ApiError {
832    fn from(e: E) -> Self {
833        Self::new(e.error_code(), e.detail())
834    }
835}
836
837// ---------------------------------------------------------------------------
838// API error — RFC 9457 Problem Details
839// ---------------------------------------------------------------------------
840
841/// [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details error response.
842///
843/// Wire format field mapping:
844///
845/// - `code` → `"type"` — URN per RFC 9457 §3.1.1 (e.g. `urn:api-bones:error:resource-not-found`)
846/// - `title` → `"title"` — RFC 9457 §3.1.2
847/// - `status` → `"status"` — HTTP status code, RFC 9457 §3.1.3 (valid range: 100–599)
848/// - `detail` → `"detail"` — RFC 9457 §3.1.4
849/// - `request_id` → `"instance"` — URI per RFC 9457 §3.1.5, as `urn:uuid:<uuid>` per RFC 4122 §3
850/// - `errors` → `"errors"` — documented extension for field-level validation errors
851///
852/// Content-Type must be set to `application/problem+json` by the HTTP layer.
853///
854/// Requires `std` or `alloc` (fields contain `String`/`Vec`).
855///
856/// # Examples
857///
858/// ```rust
859/// use api_bones::error::{ApiError, ErrorCode};
860///
861/// let err = ApiError::new(ErrorCode::ResourceNotFound, "User 42 not found");
862/// assert_eq!(err.status, 404);
863/// assert_eq!(err.detail, "User 42 not found");
864/// ```
865#[cfg(any(feature = "std", feature = "alloc"))]
866#[derive(Debug, Clone)]
867#[cfg_attr(
868    all(feature = "std", feature = "serde"),
869    derive(Serialize, Deserialize)
870)]
871#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
874pub struct ApiError {
875    /// Machine-readable error URN (RFC 9457 §3.1.1 `type`).
876    #[cfg_attr(all(feature = "std", feature = "serde"), serde(rename = "type"))]
877    pub code: ErrorCode,
878    /// Human-friendly error label (RFC 9457 §3.1.2 `title`).
879    pub title: String,
880    /// HTTP status code (RFC 9457 §3.1.3 `status`). Valid range: 100–599.
881    pub status: u16,
882    /// Human-readable error specifics (RFC 9457 §3.1.4 `detail`).
883    pub detail: String,
884    /// URI identifying this specific occurrence (RFC 9457 §3.1.5 `instance`).
885    /// Serialized as `urn:uuid:<uuid>` per RFC 4122 §3.
886    #[cfg(feature = "uuid")]
887    #[cfg_attr(
888        all(feature = "std", feature = "serde"),
889        serde(
890            rename = "instance",
891            default,
892            skip_serializing_if = "Option::is_none",
893            with = "uuid_urn_option"
894        )
895    )]
896    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
897    pub request_id: Option<uuid::Uuid>,
898    /// Structured field-level validation errors (extension). Omitted when empty.
899    #[cfg_attr(
900        all(feature = "std", feature = "serde"),
901        serde(default, skip_serializing_if = "Vec::is_empty")
902    )]
903    pub errors: Vec<ValidationError>,
904    /// Structured rate-limit metadata (extension). Present on 429 responses
905    /// when built via [`ApiError::rate_limited_with`] or
906    /// [`ApiError::with_rate_limit`]. Serialized as the top-level
907    /// `rate_limit` member.
908    #[cfg_attr(
909        all(feature = "std", feature = "serde"),
910        serde(default, skip_serializing_if = "Option::is_none")
911    )]
912    #[cfg_attr(feature = "arbitrary", arbitrary(default))]
913    pub rate_limit: Option<crate::ratelimit::RateLimitInfo>,
914    /// Upstream error that caused this `ApiError`, if any.
915    ///
916    /// Not serialized — for in-process error chaining only. Exposed via
917    /// [`core::error::Error::source`] so that `anyhow`, `eyre`, and tracing
918    /// can walk the full error chain.
919    ///
920    /// Requires `std` or `alloc` (uses `Arc`).
921    #[cfg(any(feature = "std", feature = "alloc"))]
922    #[cfg_attr(all(feature = "std", feature = "serde"), serde(skip))]
923    #[cfg_attr(feature = "utoipa", schema(value_type = (), ignore))]
924    #[cfg_attr(feature = "schemars", schemars(skip))]
925    #[cfg_attr(feature = "arbitrary", arbitrary(default))]
926    pub source: Option<Arc<dyn core::error::Error + Send + Sync + 'static>>,
927    /// Nested cause chain serialized as RFC 9457 extension member `"causes"`.
928    ///
929    /// Each entry is a nested Problem Details object. Omitted when empty.
930    /// Preserved through [`From`] conversions.
931    #[cfg_attr(
932        all(feature = "std", feature = "serde"),
933        serde(default, skip_serializing_if = "Vec::is_empty")
934    )]
935    #[cfg_attr(feature = "arbitrary", arbitrary(default))]
936    // `Vec<Self>` would cause utoipa's schema generator to recurse infinitely.
937    // Use `Object` (anonymous open schema) to represent nested cause objects.
938    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<Object>))]
939    pub causes: Vec<Self>,
940    /// Arbitrary RFC 9457 extension members attached by the caller.
941    ///
942    /// Serialized **inline** at the top level of the JSON object (flattened).
943    /// Keys must not collide with the standard Problem Details fields.
944    ///
945    /// Use [`ApiError::with_extension`] to attach values.
946    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
947    #[cfg_attr(all(feature = "std", feature = "serde"), serde(flatten))]
948    #[cfg_attr(feature = "arbitrary", arbitrary(default))]
949    pub extensions: BTreeMap<String, serde_json::Value>,
950}
951
952#[cfg(any(feature = "std", feature = "alloc"))]
953impl PartialEq for ApiError {
954    fn eq(&self, other: &Self) -> bool {
955        // `source` is intentionally excluded: trait objects have no meaningful
956        // equality and the field is not part of the wire format.
957        self.code == other.code
958            && self.title == other.title
959            && self.status == other.status
960            && self.detail == other.detail
961            && self.errors == other.errors
962            && self.rate_limit == other.rate_limit
963            && self.causes == other.causes
964            // extensions only exist when serde is on
965            && {
966                #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
967                { self.extensions == other.extensions }
968                #[cfg(not(all(any(feature = "std", feature = "alloc"), feature = "serde")))]
969                true
970            }
971            // request_id only exists when the `uuid` feature is on
972            && {
973                #[cfg(feature = "uuid")]
974                { self.request_id == other.request_id }
975                #[cfg(not(feature = "uuid"))]
976                true
977            }
978    }
979}
980
981/// Serde module: serialize/deserialize `Option<Uuid>` as `"urn:uuid:<uuid>"` strings.
982/// Used for the RFC 9457 §3.1.5 `instance` field (RFC 4122 §3 `urn:uuid:` scheme).
983#[cfg(all(
984    feature = "serde",
985    feature = "uuid",
986    any(feature = "std", feature = "alloc")
987))]
988mod uuid_urn_option {
989    use serde::{Deserialize, Deserializer, Serializer};
990
991    #[allow(clippy::ref_option)] // serde `with` module requires &Option<T> — not caller-controlled
992    pub fn serialize<S: Serializer>(uuid: &Option<uuid::Uuid>, s: S) -> Result<S::Ok, S::Error> {
993        match uuid {
994            Some(id) => s.serialize_str(&format!("urn:uuid:{id}")),
995            None => s.serialize_none(),
996        }
997    }
998
999    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<uuid::Uuid>, D::Error> {
1000        let opt = Option::<String>::deserialize(d)?;
1001        match opt {
1002            None => Ok(None),
1003            Some(ref urn) => {
1004                let hex = urn.strip_prefix("urn:uuid:").ok_or_else(|| {
1005                    serde::de::Error::custom(format!("expected urn:uuid: prefix, got {urn}"))
1006                })?;
1007                hex.parse::<uuid::Uuid>()
1008                    .map(Some)
1009                    .map_err(serde::de::Error::custom)
1010            }
1011        }
1012    }
1013}
1014
1015#[cfg(any(feature = "std", feature = "alloc"))]
1016impl ApiError {
1017    /// Create a new `ApiError`. `title` and `status` are derived from `code`.
1018    ///
1019    /// # Examples
1020    ///
1021    /// ```rust
1022    /// use api_bones::error::{ApiError, ErrorCode};
1023    ///
1024    /// let err = ApiError::new(ErrorCode::BadRequest, "missing field");
1025    /// assert_eq!(err.status, 400);
1026    /// assert_eq!(err.title, "Bad Request");
1027    /// assert_eq!(err.detail, "missing field");
1028    /// ```
1029    pub fn new(code: ErrorCode, detail: impl Into<String>) -> Self {
1030        let status = code.status_code();
1031        debug_assert!(
1032            (100..=599).contains(&status),
1033            "status {status} is not a valid HTTP status code (RFC 9457 §3.1.3 requires 100–599)"
1034        );
1035        Self {
1036            title: code.title().to_owned(),
1037            status,
1038            detail: detail.into(),
1039            code,
1040            #[cfg(feature = "uuid")]
1041            request_id: None,
1042            errors: Vec::new(),
1043            rate_limit: None,
1044            source: None,
1045            causes: Vec::new(),
1046            #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1047            extensions: BTreeMap::new(),
1048        }
1049    }
1050
1051    /// Attach a request ID (typically set by tracing middleware).
1052    /// Serializes as `"instance": "urn:uuid:<id>"` per RFC 9457 §3.1.5 + RFC 4122 §3.
1053    ///
1054    /// # Examples
1055    ///
1056    /// ```rust
1057    /// use api_bones::error::{ApiError, ErrorCode};
1058    /// use uuid::Uuid;
1059    ///
1060    /// let err = ApiError::new(ErrorCode::BadRequest, "oops")
1061    ///     .with_request_id(Uuid::nil());
1062    /// assert_eq!(err.request_id, Some(Uuid::nil()));
1063    /// ```
1064    #[cfg(feature = "uuid")]
1065    #[must_use]
1066    pub fn with_request_id(mut self, id: uuid::Uuid) -> Self {
1067        self.request_id = Some(id);
1068        self
1069    }
1070
1071    /// Attach structured field-level validation errors.
1072    ///
1073    /// # Examples
1074    ///
1075    /// ```rust
1076    /// use api_bones::error::{ApiError, ErrorCode, ValidationError};
1077    ///
1078    /// let err = ApiError::new(ErrorCode::ValidationFailed, "invalid input")
1079    ///     .with_errors(vec![
1080    ///         ValidationError { field: "/email".into(), message: "invalid".into(), rule: None },
1081    ///     ]);
1082    /// assert_eq!(err.errors.len(), 1);
1083    /// ```
1084    #[must_use]
1085    pub fn with_errors(mut self, errors: Vec<ValidationError>) -> Self {
1086        self.errors = errors;
1087        self
1088    }
1089
1090    /// Attach an upstream error as the `source()` for this `ApiError`.
1091    ///
1092    /// The source is exposed via [`core::error::Error::source`] for error-chain
1093    /// tools (`anyhow`, `eyre`, tracing) but is **not** serialized to the wire.
1094    ///
1095    /// Requires `std` or `alloc` (uses `Arc`).
1096    #[cfg(any(feature = "std", feature = "alloc"))]
1097    #[must_use]
1098    pub fn with_source(mut self, source: impl core::error::Error + Send + Sync + 'static) -> Self {
1099        self.source = Some(Arc::new(source));
1100        self
1101    }
1102
1103    /// Attach a chain of nested cause errors, serialized as `"causes"` in
1104    /// Problem Details output.
1105    ///
1106    /// # Examples
1107    ///
1108    /// ```rust
1109    /// use api_bones::error::{ApiError, ErrorCode};
1110    ///
1111    /// let cause = ApiError::not_found("upstream resource missing");
1112    /// let err = ApiError::internal("pipeline failed")
1113    ///     .with_causes(vec![cause]);
1114    /// assert_eq!(err.causes.len(), 1);
1115    /// ```
1116    #[must_use]
1117    pub fn with_causes(mut self, causes: Vec<Self>) -> Self {
1118        self.causes = causes;
1119        self
1120    }
1121
1122    /// Attach a single arbitrary RFC 9457 extension member.
1123    ///
1124    /// The value is serialized **inline** (flattened) in the Problem Details
1125    /// object. Requires the `serde` feature.
1126    ///
1127    /// # Examples
1128    ///
1129    /// ```rust
1130    /// use api_bones::error::ApiError;
1131    ///
1132    /// let err = ApiError::internal("boom")
1133    ///     .with_extension("trace_id", "abc-123");
1134    /// # #[cfg(feature = "serde")]
1135    /// assert_eq!(err.extensions["trace_id"], "abc-123");
1136    /// ```
1137    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1138    #[must_use]
1139    pub fn with_extension(
1140        mut self,
1141        key: impl Into<String>,
1142        value: impl Into<serde_json::Value>,
1143    ) -> Self {
1144        self.extensions.insert(key.into(), value.into());
1145        self
1146    }
1147
1148    /// HTTP status code.
1149    #[must_use]
1150    pub fn status_code(&self) -> u16 {
1151        self.status
1152    }
1153
1154    /// Whether this is a client error (4xx).
1155    ///
1156    /// # Examples
1157    ///
1158    /// ```rust
1159    /// use api_bones::error::{ApiError, ErrorCode};
1160    ///
1161    /// assert!(ApiError::not_found("gone").is_client_error());
1162    /// assert!(!ApiError::internal("boom").is_client_error());
1163    /// ```
1164    #[must_use]
1165    pub fn is_client_error(&self) -> bool {
1166        self.status < 500
1167    }
1168
1169    /// Whether this is a server error (5xx).
1170    ///
1171    /// # Examples
1172    ///
1173    /// ```rust
1174    /// use api_bones::error::{ApiError, ErrorCode};
1175    ///
1176    /// assert!(ApiError::internal("boom").is_server_error());
1177    /// assert!(!ApiError::not_found("gone").is_server_error());
1178    /// ```
1179    #[must_use]
1180    pub fn is_server_error(&self) -> bool {
1181        self.status >= 500
1182    }
1183
1184    // -----------------------------------------------------------------------
1185    // Convenience constructors
1186    // -----------------------------------------------------------------------
1187
1188    /// 400 Bad Request.
1189    ///
1190    /// # Examples
1191    ///
1192    /// ```rust
1193    /// use api_bones::error::ApiError;
1194    ///
1195    /// let err = ApiError::bad_request("missing param");
1196    /// assert_eq!(err.status, 400);
1197    /// assert_eq!(err.title, "Bad Request");
1198    /// ```
1199    pub fn bad_request(msg: impl Into<String>) -> Self {
1200        Self::new(ErrorCode::BadRequest, msg)
1201    }
1202
1203    /// 400 Validation Failed.
1204    pub fn validation_failed(msg: impl Into<String>) -> Self {
1205        Self::new(ErrorCode::ValidationFailed, msg)
1206    }
1207
1208    /// 401 Unauthorized.
1209    pub fn unauthorized(msg: impl Into<String>) -> Self {
1210        Self::new(ErrorCode::Unauthorized, msg)
1211    }
1212
1213    /// 401 Invalid Credentials.
1214    #[must_use]
1215    pub fn invalid_credentials() -> Self {
1216        Self::new(ErrorCode::InvalidCredentials, "Invalid credentials")
1217    }
1218
1219    /// 401 Token Expired.
1220    #[must_use]
1221    pub fn token_expired() -> Self {
1222        Self::new(ErrorCode::TokenExpired, "Token has expired")
1223    }
1224
1225    /// 403 Forbidden.
1226    pub fn forbidden(msg: impl Into<String>) -> Self {
1227        Self::new(ErrorCode::Forbidden, msg)
1228    }
1229
1230    /// 403 Insufficient Permissions.
1231    pub fn insufficient_permissions(msg: impl Into<String>) -> Self {
1232        Self::new(ErrorCode::InsufficientPermissions, msg)
1233    }
1234
1235    /// 404 Resource Not Found.
1236    ///
1237    /// # Examples
1238    ///
1239    /// ```rust
1240    /// use api_bones::error::ApiError;
1241    ///
1242    /// let err = ApiError::not_found("user 42 not found");
1243    /// assert_eq!(err.status, 404);
1244    /// assert_eq!(err.title, "Resource Not Found");
1245    /// ```
1246    pub fn not_found(msg: impl Into<String>) -> Self {
1247        Self::new(ErrorCode::ResourceNotFound, msg)
1248    }
1249
1250    /// 409 Conflict.
1251    pub fn conflict(msg: impl Into<String>) -> Self {
1252        Self::new(ErrorCode::Conflict, msg)
1253    }
1254
1255    /// 409 Resource Already Exists.
1256    pub fn already_exists(msg: impl Into<String>) -> Self {
1257        Self::new(ErrorCode::ResourceAlreadyExists, msg)
1258    }
1259
1260    /// 422 Unprocessable Entity.
1261    pub fn unprocessable(msg: impl Into<String>) -> Self {
1262        Self::new(ErrorCode::UnprocessableEntity, msg)
1263    }
1264
1265    /// 429 Rate Limited.
1266    #[must_use]
1267    pub fn rate_limited(retry_after_seconds: u64) -> Self {
1268        Self::new(
1269            ErrorCode::RateLimited,
1270            format!("Rate limited, retry after {retry_after_seconds}s"),
1271        )
1272    }
1273
1274    /// Attach structured [`RateLimitInfo`](crate::ratelimit::RateLimitInfo)
1275    /// metadata. Serialized as the top-level `rate_limit` member and
1276    /// propagated to [`ProblemJson`] as an extension of the same name.
1277    ///
1278    /// # Examples
1279    ///
1280    /// ```rust
1281    /// use api_bones::error::ApiError;
1282    /// use api_bones::ratelimit::RateLimitInfo;
1283    ///
1284    /// let info = RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(60);
1285    /// let err = ApiError::rate_limited(60).with_rate_limit(info.clone());
1286    /// assert_eq!(err.rate_limit.as_ref(), Some(&info));
1287    /// ```
1288    #[must_use]
1289    pub fn with_rate_limit(mut self, info: crate::ratelimit::RateLimitInfo) -> Self {
1290        self.rate_limit = Some(info);
1291        self
1292    }
1293
1294    /// 429 Rate Limited with structured quota metadata.
1295    ///
1296    /// Convenience over [`ApiError::rate_limited`] +
1297    /// [`ApiError::with_rate_limit`]. The `detail` string is derived from
1298    /// `info.retry_after` when set.
1299    ///
1300    /// # Examples
1301    ///
1302    /// ```rust
1303    /// use api_bones::error::ApiError;
1304    /// use api_bones::ratelimit::RateLimitInfo;
1305    ///
1306    /// let info = RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(30);
1307    /// let err = ApiError::rate_limited_with(info);
1308    /// assert_eq!(err.status, 429);
1309    /// assert!(err.rate_limit.is_some());
1310    /// ```
1311    #[must_use]
1312    pub fn rate_limited_with(info: crate::ratelimit::RateLimitInfo) -> Self {
1313        let detail = match info.retry_after {
1314            Some(secs) => format!("Rate limited, retry after {secs}s"),
1315            None => "Rate limited".to_string(),
1316        };
1317        Self::new(ErrorCode::RateLimited, detail).with_rate_limit(info)
1318    }
1319
1320    /// 500 Internal Server Error. **Never expose internal details in `msg`.**
1321    pub fn internal(msg: impl Into<String>) -> Self {
1322        Self::new(ErrorCode::InternalServerError, msg)
1323    }
1324
1325    /// 503 Service Unavailable.
1326    pub fn unavailable(msg: impl Into<String>) -> Self {
1327        Self::new(ErrorCode::ServiceUnavailable, msg)
1328    }
1329
1330    /// Return a typed builder for constructing an `ApiError`.
1331    ///
1332    /// Required fields (`code` and `detail`) must be set before calling
1333    /// [`ApiErrorBuilder::build`]; the compiler enforces this via typestate.
1334    ///
1335    /// # Example
1336    /// ```rust
1337    /// use api_bones::error::{ApiError, ErrorCode};
1338    ///
1339    /// let err = ApiError::builder()
1340    ///     .code(ErrorCode::ResourceNotFound)
1341    ///     .detail("Booking 123 not found")
1342    ///     .build();
1343    /// assert_eq!(err.status, 404);
1344    /// ```
1345    #[must_use]
1346    pub fn builder() -> ApiErrorBuilder<(), ()> {
1347        ApiErrorBuilder {
1348            code: (),
1349            detail: (),
1350            #[cfg(feature = "uuid")]
1351            request_id: None,
1352            errors: Vec::new(),
1353            causes: Vec::new(),
1354        }
1355    }
1356
1357    #[cfg(feature = "uuid")]
1358    fn with_request_id_opt(mut self, id: Option<uuid::Uuid>) -> Self {
1359        self.request_id = id;
1360        self
1361    }
1362
1363    #[cfg(not(feature = "uuid"))]
1364    fn with_request_id_opt(self, _id: Option<()>) -> Self {
1365        self
1366    }
1367}
1368
1369// ---------------------------------------------------------------------------
1370// ApiError builder — typestate
1371// ---------------------------------------------------------------------------
1372
1373/// Typestate builder for [`ApiError`].
1374///
1375/// Type parameters track whether required fields have been set:
1376/// - `C` — `ErrorCode` once `.code()` is called, `()` otherwise
1377/// - `D` — `String` once `.detail()` is called, `()` otherwise
1378///
1379/// [`ApiErrorBuilder::build`] is only available when both are set.
1380///
1381/// Requires `std` or `alloc`.
1382#[cfg(any(feature = "std", feature = "alloc"))]
1383pub struct ApiErrorBuilder<C, D> {
1384    code: C,
1385    detail: D,
1386    #[cfg(feature = "uuid")]
1387    request_id: Option<uuid::Uuid>,
1388    errors: Vec<ValidationError>,
1389    causes: Vec<ApiError>,
1390}
1391
1392#[cfg(any(feature = "std", feature = "alloc"))]
1393impl<D> ApiErrorBuilder<(), D> {
1394    /// Set the error code. `title` and `status` are derived from it automatically.
1395    pub fn code(self, code: ErrorCode) -> ApiErrorBuilder<ErrorCode, D> {
1396        ApiErrorBuilder {
1397            code,
1398            detail: self.detail,
1399            #[cfg(feature = "uuid")]
1400            request_id: self.request_id,
1401            errors: self.errors,
1402            causes: self.causes,
1403        }
1404    }
1405}
1406
1407#[cfg(any(feature = "std", feature = "alloc"))]
1408impl<C> ApiErrorBuilder<C, ()> {
1409    /// Set the human-readable error detail message.
1410    pub fn detail(self, detail: impl Into<String>) -> ApiErrorBuilder<C, String> {
1411        ApiErrorBuilder {
1412            code: self.code,
1413            detail: detail.into(),
1414            #[cfg(feature = "uuid")]
1415            request_id: self.request_id,
1416            errors: self.errors,
1417            causes: self.causes,
1418        }
1419    }
1420}
1421
1422#[cfg(any(feature = "std", feature = "alloc"))]
1423impl<C, D> ApiErrorBuilder<C, D> {
1424    /// Attach a request ID.
1425    #[cfg(feature = "uuid")]
1426    #[must_use]
1427    pub fn request_id(mut self, id: uuid::Uuid) -> Self {
1428        self.request_id = Some(id);
1429        self
1430    }
1431
1432    /// Attach structured field-level validation errors.
1433    #[must_use]
1434    pub fn errors(mut self, errors: Vec<ValidationError>) -> Self {
1435        self.errors = errors;
1436        self
1437    }
1438
1439    /// Attach a chain of nested cause errors (serialized as `"causes"`).
1440    #[must_use]
1441    pub fn causes(mut self, causes: Vec<ApiError>) -> Self {
1442        self.causes = causes;
1443        self
1444    }
1445}
1446
1447#[cfg(any(feature = "std", feature = "alloc"))]
1448impl ApiErrorBuilder<ErrorCode, String> {
1449    /// Build the [`ApiError`].
1450    ///
1451    /// Only available once both `code` and `detail` have been set.
1452    #[must_use]
1453    pub fn build(self) -> ApiError {
1454        #[cfg(feature = "uuid")]
1455        let built = ApiError::new(self.code, self.detail).with_request_id_opt(self.request_id);
1456        #[cfg(not(feature = "uuid"))]
1457        let built = ApiError::new(self.code, self.detail).with_request_id_opt(None::<()>);
1458        built.with_errors(self.errors).with_causes(self.causes)
1459    }
1460}
1461
1462#[cfg(any(feature = "std", feature = "alloc"))]
1463impl fmt::Display for ApiError {
1464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1465        write!(f, "[{}] {}", self.code, self.detail)
1466    }
1467}
1468
1469#[cfg(any(feature = "std", feature = "alloc"))]
1470impl core::error::Error for ApiError {
1471    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
1472        self.source
1473            .as_deref()
1474            .map(|s| s as &(dyn core::error::Error + 'static))
1475    }
1476}
1477
1478// ---------------------------------------------------------------------------
1479// proptest::arbitrary::Arbitrary for ApiError
1480// ---------------------------------------------------------------------------
1481// uuid::Uuid does not implement proptest::arbitrary::Arbitrary, so we write
1482// a manual Strategy that constructs a Uuid from a random u128 value.
1483
1484#[cfg(all(
1485    feature = "proptest",
1486    feature = "uuid",
1487    any(feature = "std", feature = "alloc")
1488))]
1489impl proptest::arbitrary::Arbitrary for ApiError {
1490    type Parameters = ();
1491    type Strategy = proptest::strategy::BoxedStrategy<Self>;
1492
1493    fn arbitrary_with((): ()) -> Self::Strategy {
1494        use proptest::prelude::*;
1495        (
1496            any::<ErrorCode>(),
1497            any::<String>(),
1498            any::<u16>(),
1499            any::<String>(),
1500            proptest::option::of(any::<u128>().prop_map(uuid::Uuid::from_u128)),
1501            any::<Vec<ValidationError>>(),
1502        )
1503            .prop_map(|(code, title, status, detail, request_id, errors)| Self {
1504                code,
1505                title,
1506                status,
1507                detail,
1508                #[cfg(feature = "uuid")]
1509                request_id,
1510                errors,
1511                rate_limit: None,
1512                source: None,
1513                causes: Vec::new(),
1514                #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1515                extensions: BTreeMap::new(),
1516            })
1517            .boxed()
1518    }
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524
1525    /// Serialises access to the global `ErrorTypeMode` and environment
1526    /// variables so that tests which mutate them cannot interfere with
1527    /// each other, even when `cargo test` runs them in parallel threads.
1528    static MODE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1529
1530    /// RAII guard that resets `ErrorTypeMode` on drop so subsequent tests
1531    /// always start from a clean slate.
1532    struct ModeGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
1533
1534    impl Drop for ModeGuard {
1535        fn drop(&mut self) {
1536            reset_error_type_mode();
1537        }
1538    }
1539
1540    /// Acquire `MODE_LOCK`, reset the cached mode, and return the guard.
1541    /// The mode is also reset when the guard is dropped.
1542    fn lock_and_reset_mode() -> ModeGuard {
1543        let guard = MODE_LOCK
1544            .lock()
1545            .unwrap_or_else(std::sync::PoisonError::into_inner);
1546        reset_error_type_mode();
1547        ModeGuard(guard)
1548    }
1549
1550    // -----------------------------------------------------------------------
1551    // TryFrom<u16> for ErrorCode — issue #152
1552    // -----------------------------------------------------------------------
1553
1554    #[test]
1555    fn error_code_try_from_u16_non_error_returns_err() {
1556        for code in [100_u16, 200, 204, 301, 302, 304] {
1557            assert!(
1558                ErrorCode::try_from(code).is_err(),
1559                "expected Err for status {code}"
1560            );
1561        }
1562    }
1563
1564    #[test]
1565    fn error_code_try_from_u16_unmapped_4xx_returns_err() {
1566        // e.g. 418 I'm a Teapot — no canonical ErrorCode variant
1567        assert!(ErrorCode::try_from(418_u16).is_err());
1568    }
1569
1570    #[test]
1571    fn error_code_try_from_u16_roundtrip() {
1572        // For every variant whose status_code() maps back uniquely, the
1573        // roundtrip ErrorCode -> u16 -> ErrorCode must succeed and match.
1574        // Variants that share a status code (e.g. BadRequest/ValidationFailed)
1575        // only roundtrip to the canonical (first-matched) variant.
1576        let canonical_variants = [
1577            ErrorCode::BadRequest,
1578            ErrorCode::Unauthorized,
1579            ErrorCode::Forbidden,
1580            ErrorCode::ResourceNotFound,
1581            ErrorCode::MethodNotAllowed,
1582            ErrorCode::NotAcceptable,
1583            ErrorCode::RequestTimeout,
1584            ErrorCode::Conflict,
1585            ErrorCode::Gone,
1586            ErrorCode::PreconditionFailed,
1587            ErrorCode::PayloadTooLarge,
1588            ErrorCode::UnsupportedMediaType,
1589            ErrorCode::UnprocessableEntity,
1590            ErrorCode::PreconditionRequired,
1591            ErrorCode::RateLimited,
1592            ErrorCode::RequestHeaderFieldsTooLarge,
1593            ErrorCode::InternalServerError,
1594            ErrorCode::NotImplemented,
1595            ErrorCode::BadGateway,
1596            ErrorCode::ServiceUnavailable,
1597            ErrorCode::GatewayTimeout,
1598        ];
1599        for variant in &canonical_variants {
1600            let status = variant.status_code();
1601            let roundtripped =
1602                ErrorCode::try_from(status).expect("canonical variant should round-trip");
1603            assert_eq!(
1604                roundtripped, *variant,
1605                "roundtrip failed for {variant:?} (status {status})"
1606            );
1607        }
1608    }
1609
1610    #[cfg(feature = "http")]
1611    #[test]
1612    fn error_code_try_from_status_code_non_error_returns_err() {
1613        use http::StatusCode;
1614        assert!(ErrorCode::try_from(StatusCode::OK).is_err());
1615        assert!(ErrorCode::try_from(StatusCode::MOVED_PERMANENTLY).is_err());
1616    }
1617
1618    #[cfg(feature = "http")]
1619    #[test]
1620    fn error_code_try_from_status_code_roundtrip() {
1621        use http::StatusCode;
1622        let pairs = [
1623            (StatusCode::NOT_FOUND, ErrorCode::ResourceNotFound),
1624            (
1625                StatusCode::INTERNAL_SERVER_ERROR,
1626                ErrorCode::InternalServerError,
1627            ),
1628            (StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited),
1629            (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
1630        ];
1631        for (sc, expected) in &pairs {
1632            assert_eq!(
1633                ErrorCode::try_from(*sc),
1634                Ok(expected.clone()),
1635                "failed for {sc}"
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn status_codes() {
1642        assert_eq!(ApiError::bad_request("x").status_code(), 400);
1643        assert_eq!(ApiError::unauthorized("x").status_code(), 401);
1644        assert_eq!(ApiError::invalid_credentials().status_code(), 401);
1645        assert_eq!(ApiError::token_expired().status_code(), 401);
1646        assert_eq!(ApiError::forbidden("x").status_code(), 403);
1647        assert_eq!(ApiError::not_found("x").status_code(), 404);
1648        assert_eq!(ApiError::conflict("x").status_code(), 409);
1649        assert_eq!(ApiError::already_exists("x").status_code(), 409);
1650        assert_eq!(ApiError::unprocessable("x").status_code(), 422);
1651        assert_eq!(ApiError::rate_limited(30).status_code(), 429);
1652        assert_eq!(ApiError::internal("x").status_code(), 500);
1653        assert_eq!(ApiError::unavailable("x").status_code(), 503);
1654    }
1655
1656    #[test]
1657    fn status_in_valid_http_range() {
1658        // RFC 9457 §3.1.3: status MUST be a valid HTTP status code (100–599)
1659        for err in [
1660            ApiError::bad_request("x"),
1661            ApiError::unauthorized("x"),
1662            ApiError::forbidden("x"),
1663            ApiError::not_found("x"),
1664            ApiError::conflict("x"),
1665            ApiError::unprocessable("x"),
1666            ApiError::rate_limited(30),
1667            ApiError::internal("x"),
1668            ApiError::unavailable("x"),
1669        ] {
1670            assert!(
1671                (100..=599).contains(&err.status),
1672                "status {} out of RFC 9457 §3.1.3 range",
1673                err.status
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn error_code_urn() {
1680        let _g = lock_and_reset_mode();
1681        assert_eq!(
1682            ErrorCode::ResourceNotFound.urn(),
1683            "urn:api-bones:error:resource-not-found"
1684        );
1685        assert_eq!(
1686            ErrorCode::ValidationFailed.urn(),
1687            "urn:api-bones:error:validation-failed"
1688        );
1689        assert_eq!(
1690            ErrorCode::InternalServerError.urn(),
1691            "urn:api-bones:error:internal-server-error"
1692        );
1693    }
1694
1695    #[test]
1696    fn error_code_from_type_uri_roundtrip() {
1697        let _g = lock_and_reset_mode();
1698        let codes = [
1699            ErrorCode::BadRequest,
1700            ErrorCode::ValidationFailed,
1701            ErrorCode::Unauthorized,
1702            ErrorCode::ResourceNotFound,
1703            ErrorCode::InternalServerError,
1704            ErrorCode::ServiceUnavailable,
1705        ];
1706        for code in &codes {
1707            let urn = code.urn();
1708            assert_eq!(ErrorCode::from_type_uri(&urn).as_ref(), Some(code));
1709        }
1710    }
1711
1712    #[test]
1713    fn error_code_from_type_uri_unknown() {
1714        let _g = lock_and_reset_mode();
1715        assert!(ErrorCode::from_type_uri("urn:api-bones:error:unknown-thing").is_none());
1716        assert!(ErrorCode::from_type_uri("RESOURCE_NOT_FOUND").is_none());
1717    }
1718
1719    #[test]
1720    fn display_format() {
1721        let _g = lock_and_reset_mode();
1722        let e = ApiError::not_found("booking 123 not found");
1723        assert_eq!(
1724            e.to_string(),
1725            "[urn:api-bones:error:resource-not-found] booking 123 not found"
1726        );
1727    }
1728
1729    #[test]
1730    fn title_populated() {
1731        let e = ApiError::not_found("x");
1732        assert_eq!(e.title, "Resource Not Found");
1733    }
1734
1735    #[cfg(feature = "uuid")]
1736    #[test]
1737    fn with_request_id() {
1738        let id = uuid::Uuid::new_v4();
1739        let e = ApiError::internal("oops").with_request_id(id);
1740        assert_eq!(e.request_id, Some(id));
1741    }
1742
1743    #[test]
1744    fn with_errors() {
1745        let e = ApiError::validation_failed("invalid input").with_errors(vec![ValidationError {
1746            field: "/email".to_owned(),
1747            message: "invalid format".to_owned(),
1748            rule: Some("format".to_owned()),
1749        }]);
1750        assert!(!e.errors.is_empty());
1751        assert_eq!(e.errors[0].field, "/email");
1752    }
1753
1754    #[cfg(feature = "serde")]
1755    #[test]
1756    fn wire_format() {
1757        let _g = lock_and_reset_mode();
1758        let e = ApiError::not_found("booking 123 not found");
1759        let json = serde_json::to_value(&e).unwrap();
1760        // RFC 9457: no custom envelope wrapper
1761        assert!(json.get("error").is_none());
1762        // RFC 9457 §3.1.1: type MUST be a URI reference
1763        assert_eq!(json["type"], "urn:api-bones:error:resource-not-found");
1764        assert_eq!(json["title"], "Resource Not Found");
1765        assert_eq!(json["status"], 404);
1766        assert_eq!(json["detail"], "booking 123 not found");
1767        // Optional fields omitted when absent
1768        assert!(json.get("instance").is_none());
1769        assert!(json.get("errors").is_none());
1770    }
1771
1772    #[cfg(all(feature = "serde", feature = "uuid"))]
1773    #[test]
1774    fn wire_format_instance_is_urn_uuid() {
1775        let _g = lock_and_reset_mode();
1776        // RFC 9457 §3.1.5: instance is a URI; RFC 4122 §3: urn:uuid: scheme
1777        let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1778        let e = ApiError::internal("oops").with_request_id(id);
1779        let json = serde_json::to_value(&e).unwrap();
1780        assert_eq!(
1781            json["instance"],
1782            "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
1783        );
1784        // Old field name must NOT appear
1785        assert!(json.get("request_id").is_none());
1786    }
1787
1788    #[cfg(feature = "serde")]
1789    #[test]
1790    fn wire_format_with_errors() {
1791        let _g = lock_and_reset_mode();
1792        let e = ApiError::validation_failed("bad input").with_errors(vec![ValidationError {
1793            field: "/name".to_owned(),
1794            message: "required".to_owned(),
1795            rule: None,
1796        }]);
1797        let json = serde_json::to_value(&e).unwrap();
1798        assert_eq!(json["type"], "urn:api-bones:error:validation-failed");
1799        assert_eq!(json["status"], 400);
1800        assert!(json["errors"].is_array());
1801        assert_eq!(json["errors"][0]["field"], "/name");
1802    }
1803
1804    #[cfg(feature = "serde")]
1805    #[test]
1806    fn snapshot_not_found() {
1807        let _g = lock_and_reset_mode();
1808        let e = ApiError::not_found("booking 123 not found");
1809        let json = serde_json::to_value(&e).unwrap();
1810        let expected = serde_json::json!({
1811            "type": "urn:api-bones:error:resource-not-found",
1812            "title": "Resource Not Found",
1813            "status": 404,
1814            "detail": "booking 123 not found"
1815        });
1816        assert_eq!(json, expected);
1817    }
1818
1819    #[cfg(feature = "serde")]
1820    #[test]
1821    fn snapshot_validation_failed_with_errors() {
1822        let _g = lock_and_reset_mode();
1823        let e = ApiError::validation_failed("invalid input").with_errors(vec![
1824            ValidationError {
1825                field: "/email".to_owned(),
1826                message: "invalid format".to_owned(),
1827                rule: Some("format".to_owned()),
1828            },
1829            ValidationError {
1830                field: "/name".to_owned(),
1831                message: "required".to_owned(),
1832                rule: None,
1833            },
1834        ]);
1835        let json = serde_json::to_value(&e).unwrap();
1836        let expected = serde_json::json!({
1837            "type": "urn:api-bones:error:validation-failed",
1838            "title": "Validation Failed",
1839            "status": 400,
1840            "detail": "invalid input",
1841            "errors": [
1842                {"field": "/email", "message": "invalid format", "rule": "format"},
1843                {"field": "/name", "message": "required"}
1844            ]
1845        });
1846        assert_eq!(json, expected);
1847    }
1848
1849    #[cfg(feature = "serde")]
1850    #[test]
1851    fn error_code_serde_roundtrip() {
1852        let _g = lock_and_reset_mode();
1853        let code = ErrorCode::ResourceNotFound;
1854        let json = serde_json::to_value(&code).unwrap();
1855        assert_eq!(json, "urn:api-bones:error:resource-not-found");
1856        let back: ErrorCode = serde_json::from_value(json).unwrap();
1857        assert_eq!(back, code);
1858    }
1859
1860    #[test]
1861    fn client_vs_server() {
1862        assert!(ApiError::not_found("x").is_client_error());
1863        assert!(!ApiError::not_found("x").is_server_error());
1864        assert!(ApiError::internal("x").is_server_error());
1865    }
1866
1867    // -----------------------------------------------------------------------
1868    // ErrorTypeMode::render — URL variant (line 105)
1869    // -----------------------------------------------------------------------
1870
1871    #[test]
1872    fn error_type_mode_render_url() {
1873        let mode = ErrorTypeMode::Url {
1874            base_url: "https://docs.example.com/errors".into(),
1875        };
1876        assert_eq!(
1877            mode.render("resource-not-found"),
1878            "https://docs.example.com/errors/resource-not-found"
1879        );
1880        // trailing slash in base_url is trimmed
1881        let mode_slash = ErrorTypeMode::Url {
1882            base_url: "https://docs.example.com/errors/".into(),
1883        };
1884        assert_eq!(
1885            mode_slash.render("bad-request"),
1886            "https://docs.example.com/errors/bad-request"
1887        );
1888    }
1889
1890    // -----------------------------------------------------------------------
1891    // set_error_type_mode + urn_namespace URL branch
1892    //
1893    // These tests mutate global state (ErrorTypeMode / env vars), so each
1894    // one resets the mode before and after via reset_error_type_mode().
1895    // -----------------------------------------------------------------------
1896
1897    #[test]
1898    fn set_error_type_mode_url_and_urn_namespace_fallback() {
1899        let _g = lock_and_reset_mode();
1900        set_error_type_mode(ErrorTypeMode::Url {
1901            base_url: "https://docs.test.com/errors".into(),
1902        });
1903        assert_eq!(
1904            error_type_mode(),
1905            ErrorTypeMode::Url {
1906                base_url: "https://docs.test.com/errors".into()
1907            }
1908        );
1909        // urn_namespace() returns "api-bones" as a safe fallback in URL mode
1910        assert_eq!(urn_namespace(), "api-bones");
1911    }
1912
1913    #[test]
1914    fn urn_namespace_urn_mode_returns_namespace() {
1915        let _g = lock_and_reset_mode();
1916        // Default mode is Urn { "api-bones" } — covers the Urn arm of urn_namespace()
1917        assert_eq!(urn_namespace(), "api-bones");
1918    }
1919
1920    // -----------------------------------------------------------------------
1921    // error_type_mode() runtime env-var branches
1922    // -----------------------------------------------------------------------
1923
1924    #[allow(unsafe_code)]
1925    #[test]
1926    fn error_type_mode_url_from_runtime_env() {
1927        let _g = lock_and_reset_mode();
1928        // Safety: single-threaded test; env var cleaned up after.
1929        unsafe {
1930            std::env::set_var(
1931                "SHARED_TYPES_ERROR_TYPE_BASE_URL",
1932                "https://env.example.com/errors",
1933            );
1934        }
1935        let mode = error_type_mode();
1936        assert!(
1937            matches!(mode, ErrorTypeMode::Url { base_url } if base_url == "https://env.example.com/errors")
1938        );
1939        unsafe {
1940            std::env::remove_var("SHARED_TYPES_ERROR_TYPE_BASE_URL");
1941        }
1942    }
1943
1944    #[allow(unsafe_code)]
1945    #[test]
1946    fn error_type_mode_urn_from_runtime_env() {
1947        let _g = lock_and_reset_mode();
1948        // Safety: single-threaded test; env var cleaned up after.
1949        unsafe {
1950            std::env::set_var("SHARED_TYPES_URN_NAMESPACE", "testapp");
1951        }
1952        let mode = error_type_mode();
1953        assert!(matches!(mode, ErrorTypeMode::Urn { namespace } if namespace == "testapp"));
1954        unsafe {
1955            std::env::remove_var("SHARED_TYPES_URN_NAMESPACE");
1956        }
1957    }
1958
1959    // -----------------------------------------------------------------------
1960    // from_type_uri — URL mode path
1961    // -----------------------------------------------------------------------
1962
1963    #[test]
1964    fn from_type_uri_url_mode_paths() {
1965        let _g = lock_and_reset_mode();
1966        set_error_type_mode(ErrorTypeMode::Url {
1967            base_url: "https://docs.test.com/errors".into(),
1968        });
1969        // Primary: URL prefix match
1970        assert_eq!(
1971            ErrorCode::from_type_uri("https://docs.test.com/errors/resource-not-found"),
1972            Some(ErrorCode::ResourceNotFound)
1973        );
1974        // Fallback: URN format still accepted in URL mode
1975        assert_eq!(
1976            ErrorCode::from_type_uri("urn:api-bones:error:bad-request"),
1977            Some(ErrorCode::BadRequest)
1978        );
1979        // URL prefix matches but slug is unknown → None (via slug match wildcard)
1980        assert!(ErrorCode::from_type_uri("https://docs.test.com/errors/totally-unknown").is_none());
1981        // Neither prefix matches → ? operator fires on the or_else result
1982        assert!(ErrorCode::from_type_uri("not-a-url-or-urn").is_none());
1983    }
1984
1985    // -----------------------------------------------------------------------
1986    // Complete coverage of all 27 ErrorCode variants:
1987    //   title(), urn_slug(), status_code(), from_type_uri() roundtrip
1988    // -----------------------------------------------------------------------
1989
1990    #[test]
1991    #[allow(clippy::too_many_lines)]
1992    fn all_error_code_variants_title_slug_status() {
1993        let _g = lock_and_reset_mode();
1994        let cases: &[(ErrorCode, &str, &str, u16)] = &[
1995            (ErrorCode::BadRequest, "Bad Request", "bad-request", 400),
1996            (
1997                ErrorCode::ValidationFailed,
1998                "Validation Failed",
1999                "validation-failed",
2000                400,
2001            ),
2002            (ErrorCode::Unauthorized, "Unauthorized", "unauthorized", 401),
2003            (
2004                ErrorCode::InvalidCredentials,
2005                "Invalid Credentials",
2006                "invalid-credentials",
2007                401,
2008            ),
2009            (
2010                ErrorCode::TokenExpired,
2011                "Token Expired",
2012                "token-expired",
2013                401,
2014            ),
2015            (
2016                ErrorCode::TokenInvalid,
2017                "Token Invalid",
2018                "token-invalid",
2019                401,
2020            ),
2021            (ErrorCode::Forbidden, "Forbidden", "forbidden", 403),
2022            (
2023                ErrorCode::InsufficientPermissions,
2024                "Insufficient Permissions",
2025                "insufficient-permissions",
2026                403,
2027            ),
2028            (
2029                ErrorCode::ResourceNotFound,
2030                "Resource Not Found",
2031                "resource-not-found",
2032                404,
2033            ),
2034            (
2035                ErrorCode::MethodNotAllowed,
2036                "Method Not Allowed",
2037                "method-not-allowed",
2038                405,
2039            ),
2040            (
2041                ErrorCode::NotAcceptable,
2042                "Not Acceptable",
2043                "not-acceptable",
2044                406,
2045            ),
2046            (
2047                ErrorCode::RequestTimeout,
2048                "Request Timeout",
2049                "request-timeout",
2050                408,
2051            ),
2052            (ErrorCode::Conflict, "Conflict", "conflict", 409),
2053            (
2054                ErrorCode::ResourceAlreadyExists,
2055                "Resource Already Exists",
2056                "resource-already-exists",
2057                409,
2058            ),
2059            (ErrorCode::Gone, "Gone", "gone", 410),
2060            (
2061                ErrorCode::PreconditionFailed,
2062                "Precondition Failed",
2063                "precondition-failed",
2064                412,
2065            ),
2066            (
2067                ErrorCode::PayloadTooLarge,
2068                "Payload Too Large",
2069                "payload-too-large",
2070                413,
2071            ),
2072            (
2073                ErrorCode::UnsupportedMediaType,
2074                "Unsupported Media Type",
2075                "unsupported-media-type",
2076                415,
2077            ),
2078            (
2079                ErrorCode::UnprocessableEntity,
2080                "Unprocessable Entity",
2081                "unprocessable-entity",
2082                422,
2083            ),
2084            (
2085                ErrorCode::PreconditionRequired,
2086                "Precondition Required",
2087                "precondition-required",
2088                428,
2089            ),
2090            (ErrorCode::RateLimited, "Rate Limited", "rate-limited", 429),
2091            (
2092                ErrorCode::RequestHeaderFieldsTooLarge,
2093                "Request Header Fields Too Large",
2094                "request-header-fields-too-large",
2095                431,
2096            ),
2097            (
2098                ErrorCode::InternalServerError,
2099                "Internal Server Error",
2100                "internal-server-error",
2101                500,
2102            ),
2103            (
2104                ErrorCode::NotImplemented,
2105                "Not Implemented",
2106                "not-implemented",
2107                501,
2108            ),
2109            (ErrorCode::BadGateway, "Bad Gateway", "bad-gateway", 502),
2110            (
2111                ErrorCode::ServiceUnavailable,
2112                "Service Unavailable",
2113                "service-unavailable",
2114                503,
2115            ),
2116            (
2117                ErrorCode::GatewayTimeout,
2118                "Gateway Timeout",
2119                "gateway-timeout",
2120                504,
2121            ),
2122        ];
2123        for (code, title, slug, status) in cases {
2124            assert_eq!(code.title(), *title, "title mismatch for {slug}");
2125            assert_eq!(code.urn_slug(), *slug, "slug mismatch");
2126            assert_eq!(code.status_code(), *status, "status mismatch for {slug}");
2127            // urn() roundtrip via from_type_uri()
2128            let urn = code.urn();
2129            assert_eq!(
2130                ErrorCode::from_type_uri(&urn).as_ref(),
2131                Some(code),
2132                "from_type_uri roundtrip failed for {urn}"
2133            );
2134        }
2135    }
2136
2137    // -----------------------------------------------------------------------
2138    // insufficient_permissions() convenience constructor (lines 515–517)
2139    // -----------------------------------------------------------------------
2140
2141    #[test]
2142    fn insufficient_permissions_constructor() {
2143        let e = ApiError::insufficient_permissions("missing admin role");
2144        assert_eq!(e.status_code(), 403);
2145        assert_eq!(e.title, "Insufficient Permissions");
2146        assert!(e.is_client_error());
2147    }
2148
2149    // -----------------------------------------------------------------------
2150    // uuid_urn_option: serialize None branch + full deserializer coverage
2151    // (lines 407, 411–424)
2152    // -----------------------------------------------------------------------
2153
2154    #[cfg(feature = "serde")]
2155    #[test]
2156    fn error_code_deserialize_non_string_is_error() {
2157        let _g = lock_and_reset_mode();
2158        // Covers the ? on String::deserialize in ErrorCode::deserialize (line 321)
2159        let result: Result<ErrorCode, _> = serde_json::from_value(serde_json::json!(42));
2160        assert!(result.is_err());
2161    }
2162
2163    #[cfg(feature = "serde")]
2164    #[test]
2165    fn error_code_deserialize_unknown_uri_is_error() {
2166        let _g = lock_and_reset_mode();
2167        // Covers ok_or_else closure in ErrorCode::deserialize (lines 322–323)
2168        let result: Result<ErrorCode, _> =
2169            serde_json::from_value(serde_json::json!("urn:api-bones:error:does-not-exist"));
2170        assert!(result.is_err());
2171    }
2172
2173    #[cfg(all(feature = "serde", feature = "uuid"))]
2174    #[test]
2175    fn uuid_urn_option_serialize_none_produces_null() {
2176        // The None arm exists for the serde `with` protocol. Since
2177        // skip_serializing_if = "Option::is_none" is set on the field, serde
2178        // never calls this in practice — test it directly.
2179        use serde_json::Serializer as JsonSerializer;
2180        let mut buf = Vec::new();
2181        let mut s = JsonSerializer::new(&mut buf);
2182        uuid_urn_option::serialize(&None, &mut s).unwrap();
2183        assert_eq!(buf, b"null");
2184    }
2185
2186    #[cfg(all(feature = "serde", feature = "uuid"))]
2187    #[test]
2188    fn uuid_urn_option_deserialize_non_string_is_error() {
2189        let _g = lock_and_reset_mode();
2190        // Covers the ? failure path in deserialize (line 415): Option<String>::deserialize
2191        // returns Err when the JSON value is not a string or null.
2192        let json = serde_json::json!({
2193            "type": "urn:api-bones:error:bad-request",
2194            "title": "Bad Request",
2195            "status": 400,
2196            "detail": "x",
2197            "instance": 42
2198        });
2199        let result: Result<ApiError, _> = serde_json::from_value(json);
2200        assert!(result.is_err());
2201    }
2202
2203    #[cfg(all(feature = "serde", feature = "uuid"))]
2204    #[test]
2205    fn uuid_urn_option_deserialize_null_gives_none() {
2206        let _g = lock_and_reset_mode();
2207        // Triggers the None arm in deserialize (line 414).
2208        let json = serde_json::json!({
2209            "type": "urn:api-bones:error:bad-request",
2210            "title": "Bad Request",
2211            "status": 400,
2212            "detail": "x",
2213            "instance": null
2214        });
2215        let e: ApiError = serde_json::from_value(json).unwrap();
2216        assert!(e.request_id.is_none());
2217    }
2218
2219    #[cfg(all(feature = "serde", feature = "uuid"))]
2220    #[test]
2221    fn uuid_urn_option_deserialize_valid_urn_uuid() {
2222        let _g = lock_and_reset_mode();
2223        // Triggers the happy-path Some arm in deserialize (lines 415–421).
2224        let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2225        let json = serde_json::json!({
2226            "type": "urn:api-bones:error:bad-request",
2227            "title": "Bad Request",
2228            "status": 400,
2229            "detail": "x",
2230            "instance": "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
2231        });
2232        let e: ApiError = serde_json::from_value(json).unwrap();
2233        assert_eq!(e.request_id, Some(id));
2234    }
2235
2236    #[cfg(all(feature = "serde", feature = "uuid"))]
2237    #[test]
2238    fn uuid_urn_option_deserialize_bad_prefix_is_error() {
2239        let _g = lock_and_reset_mode();
2240        // Triggers the ok_or_else error path (lines 416–418).
2241        let json = serde_json::json!({
2242            "type": "urn:api-bones:error:bad-request",
2243            "title": "Bad Request",
2244            "status": 400,
2245            "detail": "x",
2246            "instance": "uuid:550e8400-e29b-41d4-a716-446655440000"
2247        });
2248        let result: Result<ApiError, _> = serde_json::from_value(json);
2249        assert!(result.is_err());
2250    }
2251
2252    // -----------------------------------------------------------------------
2253    // ApiError builder tests
2254    // -----------------------------------------------------------------------
2255
2256    #[cfg(feature = "uuid")]
2257    #[test]
2258    fn builder_basic() {
2259        let err = ApiError::builder()
2260            .code(ErrorCode::ResourceNotFound)
2261            .detail("Booking 123 not found")
2262            .build();
2263        assert_eq!(err.status, 404);
2264        assert_eq!(err.title, "Resource Not Found");
2265        assert_eq!(err.detail, "Booking 123 not found");
2266        assert!(err.request_id.is_none());
2267        assert!(err.errors.is_empty());
2268    }
2269
2270    #[test]
2271    fn builder_equivalence_with_new() {
2272        let via_new = ApiError::new(ErrorCode::BadRequest, "bad");
2273        let via_builder = ApiError::builder()
2274            .code(ErrorCode::BadRequest)
2275            .detail("bad")
2276            .build();
2277        assert_eq!(via_new, via_builder);
2278    }
2279
2280    #[cfg(feature = "uuid")]
2281    #[test]
2282    fn builder_chaining_all_optionals() {
2283        let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2284        let errs = vec![ValidationError {
2285            field: "/email".to_owned(),
2286            message: "invalid".to_owned(),
2287            rule: None,
2288        }];
2289        let err = ApiError::builder()
2290            .code(ErrorCode::ValidationFailed)
2291            .detail("invalid input")
2292            .request_id(id)
2293            .errors(errs.clone())
2294            .build();
2295        assert_eq!(err.request_id, Some(id));
2296        assert_eq!(err.errors, errs);
2297    }
2298
2299    #[test]
2300    fn builder_detail_before_code() {
2301        // Typestate allows setting detail before code
2302        let err = ApiError::builder()
2303            .detail("forbidden action")
2304            .code(ErrorCode::Forbidden)
2305            .build();
2306        assert_eq!(err.status, 403);
2307        assert_eq!(err.detail, "forbidden action");
2308    }
2309
2310    // -----------------------------------------------------------------------
2311    // Error source() chaining — issue #37
2312    // -----------------------------------------------------------------------
2313
2314    #[test]
2315    fn api_error_source_none_by_default() {
2316        use std::error::Error;
2317        let err = ApiError::not_found("booking 42");
2318        assert!(err.source().is_none());
2319    }
2320
2321    #[test]
2322    fn api_error_with_source_chain_is_walkable() {
2323        use std::error::Error;
2324
2325        #[derive(Debug)]
2326        struct RootCause;
2327        impl std::fmt::Display for RootCause {
2328            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2329                f.write_str("database connection refused")
2330            }
2331        }
2332        impl Error for RootCause {}
2333
2334        let err = ApiError::internal("upstream failure").with_source(RootCause);
2335
2336        // source() returns the attached cause
2337        let source = err.source().expect("source should be set");
2338        assert_eq!(source.to_string(), "database connection refused");
2339
2340        // chain ends after one hop
2341        assert!(source.source().is_none());
2342    }
2343
2344    #[test]
2345    fn api_error_source_chain_two_levels() {
2346        use std::error::Error;
2347
2348        #[derive(Debug)]
2349        struct Mid(std::io::Error);
2350        impl std::fmt::Display for Mid {
2351            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2352                write!(f, "mid: {}", self.0)
2353            }
2354        }
2355        impl Error for Mid {
2356            fn source(&self) -> Option<&(dyn Error + 'static)> {
2357                Some(&self.0)
2358            }
2359        }
2360
2361        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
2362        let mid = Mid(io_err);
2363
2364        let err = ApiError::unavailable("service down").with_source(mid);
2365
2366        let hop1 = err.source().expect("first source");
2367        assert!(hop1.to_string().starts_with("mid:"));
2368
2369        let hop2 = hop1.source().expect("second source");
2370        assert_eq!(hop2.to_string(), "timed out");
2371    }
2372
2373    #[test]
2374    fn api_error_partial_eq_ignores_source() {
2375        #[derive(Debug)]
2376        struct Cause;
2377        impl std::fmt::Display for Cause {
2378            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2379                f.write_str("cause")
2380            }
2381        }
2382        impl std::error::Error for Cause {}
2383
2384        // Exercise the Display impl (required by std::error::Error) so coverage is satisfied.
2385        assert_eq!(Cause.to_string(), "cause");
2386        let a = ApiError::not_found("x");
2387        let b = ApiError::not_found("x").with_source(Cause);
2388        // source is intentionally excluded from PartialEq
2389        assert_eq!(a, b);
2390    }
2391
2392    #[test]
2393    fn api_error_with_source_is_cloneable() {
2394        use std::error::Error;
2395
2396        #[derive(Debug)]
2397        struct Cause;
2398        impl std::fmt::Display for Cause {
2399            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2400                f.write_str("cause")
2401            }
2402        }
2403        impl Error for Cause {}
2404
2405        // Exercise Display (required by std::error::Error) for coverage.
2406        assert_eq!(Cause.to_string(), "cause");
2407        let a = ApiError::internal("oops").with_source(Cause);
2408        // Clone is derived; Arc clone shares the allocation.
2409        let b = a.clone();
2410        // Both a and b must have source set — verify both are still usable.
2411        assert!(a.source().is_some());
2412        assert!(b.source().is_some());
2413    }
2414
2415    #[test]
2416    fn validation_error_display_with_rule() {
2417        let ve = ValidationError {
2418            field: "/email".to_owned(),
2419            message: "invalid format".to_owned(),
2420            rule: Some("format".to_owned()),
2421        };
2422        assert_eq!(ve.to_string(), "/email: invalid format (rule: format)");
2423    }
2424
2425    #[test]
2426    fn validation_error_display_without_rule() {
2427        let ve = ValidationError {
2428            field: "/name".to_owned(),
2429            message: "required".to_owned(),
2430            rule: None,
2431        };
2432        assert_eq!(ve.to_string(), "/name: required");
2433    }
2434
2435    #[test]
2436    fn validation_error_is_std_error() {
2437        use std::error::Error;
2438        let ve = ValidationError {
2439            field: "/age".to_owned(),
2440            message: "must be positive".to_owned(),
2441            rule: Some("min".to_owned()),
2442        };
2443        // source() is None — ValidationError has no inner cause
2444        assert!(ve.source().is_none());
2445        // usable as &dyn Error
2446        let _: &dyn Error = &ve;
2447    }
2448
2449    #[test]
2450    fn api_error_source_downcast() {
2451        use std::error::Error;
2452        use std::sync::Arc;
2453
2454        #[derive(Debug)]
2455        struct Typed(u32);
2456        impl std::fmt::Display for Typed {
2457            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2458                write!(f, "typed({})", self.0)
2459            }
2460        }
2461        impl Error for Typed {}
2462
2463        // Exercise Display (required by std::error::Error) for coverage.
2464        assert_eq!(Typed(7).to_string(), "typed(7)");
2465        let err = ApiError::internal("oops").with_source(Typed(42));
2466        let source_arc: &Arc<dyn Error + Send + Sync> = err.source.as_ref().expect("source set");
2467        let downcasted = source_arc.downcast_ref::<Typed>();
2468        assert!(downcasted.is_some());
2469        assert_eq!(downcasted.unwrap().0, 42);
2470    }
2471
2472    // -----------------------------------------------------------------------
2473    // schemars
2474    // -----------------------------------------------------------------------
2475
2476    #[cfg(feature = "schemars")]
2477    #[test]
2478    fn error_code_schema_is_valid() {
2479        let schema = schemars::schema_for!(ErrorCode);
2480        let json = serde_json::to_value(&schema).expect("schema serializable");
2481        assert!(json.is_object(), "schema should be a JSON object");
2482    }
2483
2484    #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
2485    #[test]
2486    fn api_error_schema_is_valid() {
2487        let schema = schemars::schema_for!(ApiError);
2488        let json = serde_json::to_value(&schema).expect("schema serializable");
2489        assert!(json.is_object());
2490        // Confirm top-level type property exists
2491        assert!(
2492            json.get("definitions").is_some()
2493                || json.get("$defs").is_some()
2494                || json.get("properties").is_some(),
2495            "schema should contain definitions or properties"
2496        );
2497    }
2498
2499    #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
2500    #[test]
2501    fn validation_error_schema_is_valid() {
2502        let schema = schemars::schema_for!(ValidationError);
2503        let json = serde_json::to_value(&schema).expect("schema serializable");
2504        assert!(json.is_object());
2505    }
2506
2507    // -----------------------------------------------------------------------
2508    // #108 — HttpError trait
2509    // -----------------------------------------------------------------------
2510
2511    #[test]
2512    fn http_error_blanket_from() {
2513        #[derive(Debug)]
2514        struct NotFound(u64);
2515
2516        impl HttpError for NotFound {
2517            fn status_code(&self) -> u16 {
2518                404
2519            }
2520            fn error_code(&self) -> ErrorCode {
2521                ErrorCode::ResourceNotFound
2522            }
2523            fn detail(&self) -> String {
2524                format!("item {} not found", self.0)
2525            }
2526        }
2527
2528        assert_eq!(NotFound(99).status_code(), 404);
2529        let err: ApiError = NotFound(99).into();
2530        assert_eq!(err.status, 404);
2531        assert_eq!(err.code, ErrorCode::ResourceNotFound);
2532        assert_eq!(err.detail, "item 99 not found");
2533    }
2534
2535    // -----------------------------------------------------------------------
2536    // #110 — nested causes
2537    // -----------------------------------------------------------------------
2538
2539    #[test]
2540    fn with_causes_roundtrip() {
2541        let cause = ApiError::not_found("upstream missing");
2542        let err = ApiError::internal("pipeline failed").with_causes(vec![cause.clone()]);
2543        assert_eq!(err.causes.len(), 1);
2544        assert_eq!(err.causes[0].detail, cause.detail);
2545    }
2546
2547    #[cfg(feature = "serde")]
2548    #[test]
2549    fn causes_serialized_as_extension() {
2550        let _g = lock_and_reset_mode();
2551        let cause = ApiError::not_found("db row missing");
2552        let err = ApiError::internal("handler failed").with_causes(vec![cause]);
2553        let json = serde_json::to_value(&err).unwrap();
2554        let causes = json["causes"].as_array().expect("causes must be array");
2555        assert_eq!(causes.len(), 1);
2556        assert_eq!(causes[0]["status"], 404);
2557        assert_eq!(causes[0]["detail"], "db row missing");
2558    }
2559
2560    #[cfg(feature = "serde")]
2561    #[test]
2562    fn causes_omitted_when_empty() {
2563        let _g = lock_and_reset_mode();
2564        let err = ApiError::internal("oops");
2565        let json = serde_json::to_value(&err).unwrap();
2566        assert!(json.get("causes").is_none());
2567    }
2568
2569    #[cfg(feature = "serde")]
2570    #[test]
2571    fn causes_propagated_through_problem_json() {
2572        use crate::error::ProblemJson;
2573        let _g = lock_and_reset_mode();
2574        let cause = ApiError::not_found("missing row");
2575        let err = ApiError::internal("failed").with_causes(vec![cause]);
2576        let p = ProblemJson::from(err);
2577        assert!(p.extensions.contains_key("causes"));
2578        let causes = p.extensions["causes"].as_array().unwrap();
2579        assert_eq!(causes.len(), 1);
2580        assert_eq!(causes[0]["status"], 404);
2581    }
2582
2583    #[test]
2584    fn builder_with_causes() {
2585        let cause = ApiError::bad_request("bad input");
2586        let err = ApiError::builder()
2587            .code(ErrorCode::UnprocessableEntity)
2588            .detail("entity failed")
2589            .causes(vec![cause.clone()])
2590            .build();
2591        assert_eq!(err.causes.len(), 1);
2592        assert_eq!(err.causes[0].detail, cause.detail);
2593    }
2594
2595    // -----------------------------------------------------------------------
2596    // #111 — custom extension members
2597    // -----------------------------------------------------------------------
2598
2599    #[cfg(feature = "serde")]
2600    #[test]
2601    fn with_extension_roundtrip() {
2602        let _g = lock_and_reset_mode();
2603        let err = ApiError::internal("boom").with_extension("trace_id", "abc-123");
2604        assert_eq!(err.extensions["trace_id"], "abc-123");
2605    }
2606
2607    #[cfg(feature = "serde")]
2608    #[test]
2609    fn extension_flattened_in_wire_format() {
2610        let _g = lock_and_reset_mode();
2611        let err = ApiError::not_found("gone").with_extension("tenant", "acme");
2612        let json = serde_json::to_value(&err).unwrap();
2613        // Extension must appear at the top level alongside standard fields.
2614        assert_eq!(json["tenant"], "acme");
2615        // Standard fields still present.
2616        assert_eq!(json["status"], 404);
2617    }
2618
2619    #[cfg(feature = "serde")]
2620    #[test]
2621    fn extension_roundtrip_ser_de() {
2622        let _g = lock_and_reset_mode();
2623        let err = ApiError::bad_request("bad").with_extension("request_num", 42_u64);
2624        let json = serde_json::to_value(&err).unwrap();
2625        let back: ApiError = serde_json::from_value(json).unwrap();
2626        assert_eq!(back.extensions["request_num"], 42_u64);
2627    }
2628
2629    #[cfg(feature = "serde")]
2630    #[test]
2631    fn extension_propagated_through_problem_json() {
2632        use crate::error::ProblemJson;
2633        let _g = lock_and_reset_mode();
2634        let err = ApiError::forbidden("denied").with_extension("policy", "read-only");
2635        let p = ProblemJson::from(err);
2636        assert_eq!(p.extensions["policy"], "read-only");
2637    }
2638}
2639
2640// ---------------------------------------------------------------------------
2641// ProblemJson — RFC 7807 / 9457 wire-format response type
2642// ---------------------------------------------------------------------------
2643
2644/// RFC 7807 / 9457 Problem Details response body with optional extension members.
2645///
2646/// Unlike [`ApiError`] (which carries in-process state such as `source` and
2647/// `Arc`), `ProblemJson` is a pure serialization type — every field maps
2648/// directly to the wire format.
2649///
2650/// The `extensions` map serializes **flat** into the JSON object, so arbitrary
2651/// key-value members (e.g. `trace_id`, `request_id`) appear at the top level
2652/// alongside the standard fields:
2653///
2654/// ```json
2655/// {
2656///   "type":     "urn:api-bones:error:resource-not-found",
2657///   "title":    "Resource Not Found",
2658///   "status":   404,
2659///   "detail":   "Booking 42 not found",
2660///   "instance": "urn:uuid:01234567-89ab-cdef-0123-456789abcdef",
2661///   "trace_id": "abc123"
2662/// }
2663/// ```
2664///
2665/// Content-Type is `application/problem+json`.
2666///
2667/// # `no_std` support
2668///
2669/// Available when either `std` or `alloc` is enabled together with `serde`
2670/// (required for `serde_json::Value` and `BTreeMap`).
2671/// Uses [`BTreeMap`](alloc::collections::BTreeMap) internally so heap
2672/// allocation is the only requirement — `std` is not needed.
2673///
2674/// # Examples
2675///
2676/// ```rust
2677/// use api_bones::error::{ApiError, ErrorCode, ProblemJson};
2678///
2679/// let err = ApiError::not_found("booking 42 not found");
2680/// let problem = ProblemJson::from(err);
2681/// assert_eq!(problem.status, 404);
2682/// assert_eq!(problem.title, "Resource Not Found");
2683/// ```
2684///
2685/// Adding extension members:
2686///
2687/// ```rust
2688/// use api_bones::error::{ApiError, ProblemJson};
2689///
2690/// let mut problem = ProblemJson::from(ApiError::internal("db timeout"));
2691/// problem.extensions.insert("trace_id".into(), "abc123".into());
2692/// assert_eq!(problem.extensions["trace_id"], "abc123");
2693/// ```
2694#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
2695#[derive(Debug, Clone, PartialEq)]
2696#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2697#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2698#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2699#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
2700#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
2701pub struct ProblemJson {
2702    /// Machine-readable error type URI (RFC 9457 §3.1.1 `type`).
2703    #[cfg_attr(feature = "serde", serde(rename = "type"))]
2704    pub r#type: String,
2705    /// Human-friendly summary (RFC 9457 §3.1.2 `title`).
2706    pub title: String,
2707    /// HTTP status code (RFC 9457 §3.1.3 `status`).
2708    pub status: u16,
2709    /// Human-readable specifics (RFC 9457 §3.1.4 `detail`).
2710    pub detail: String,
2711    /// URI identifying this occurrence (RFC 9457 §3.1.5 `instance`).
2712    #[cfg_attr(
2713        feature = "serde",
2714        serde(default, skip_serializing_if = "Option::is_none")
2715    )]
2716    pub instance: Option<String>,
2717    /// Flat extension members (e.g. `trace_id`, `request_id`).
2718    ///
2719    /// Serialized **inline** at the top level of the JSON object via
2720    /// `#[serde(flatten)]`. Keys must not collide with the standard fields.
2721    /// Uses [`BTreeMap`](alloc::collections::BTreeMap) for `no_std` compatibility.
2722    #[cfg_attr(feature = "serde", serde(flatten))]
2723    #[cfg_attr(feature = "schemars", schemars(skip))]
2724    #[cfg_attr(feature = "arbitrary", arbitrary(default))]
2725    #[cfg_attr(
2726        feature = "proptest",
2727        proptest(strategy = "proptest::strategy::Just(BTreeMap::new())")
2728    )]
2729    pub extensions: BTreeMap<String, serde_json::Value>,
2730}
2731
2732#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
2733impl ProblemJson {
2734    /// Build a `ProblemJson` directly from its components.
2735    ///
2736    /// # Examples
2737    ///
2738    /// ```rust
2739    /// use api_bones::error::ProblemJson;
2740    ///
2741    /// let p = ProblemJson::new(
2742    ///     "urn:api-bones:error:bad-request",
2743    ///     "Bad Request",
2744    ///     400,
2745    ///     "missing field `email`",
2746    /// );
2747    /// assert_eq!(p.status, 400);
2748    /// assert!(p.extensions.is_empty());
2749    /// ```
2750    #[must_use]
2751    pub fn new(
2752        r#type: impl Into<String>,
2753        title: impl Into<String>,
2754        status: u16,
2755        detail: impl Into<String>,
2756    ) -> Self {
2757        Self {
2758            r#type: r#type.into(),
2759            title: title.into(),
2760            status,
2761            detail: detail.into(),
2762            instance: None,
2763            extensions: BTreeMap::new(),
2764        }
2765    }
2766
2767    /// Set the `instance` field (RFC 9457 §3.1.5).
2768    ///
2769    /// # Examples
2770    ///
2771    /// ```rust
2772    /// use api_bones::error::ProblemJson;
2773    ///
2774    /// let p = ProblemJson::new("urn:api-bones:error:bad-request", "Bad Request", 400, "oops")
2775    ///     .with_instance("urn:uuid:00000000-0000-0000-0000-000000000000");
2776    /// assert!(p.instance.is_some());
2777    /// ```
2778    #[must_use]
2779    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
2780        self.instance = Some(instance.into());
2781        self
2782    }
2783
2784    /// Insert an extension member.
2785    ///
2786    /// # Examples
2787    ///
2788    /// ```rust
2789    /// use api_bones::error::ProblemJson;
2790    ///
2791    /// let mut p = ProblemJson::new("urn:api-bones:error:bad-request", "Bad Request", 400, "oops");
2792    /// p.extend("trace_id", "abc123");
2793    /// assert_eq!(p.extensions["trace_id"], "abc123");
2794    /// ```
2795    pub fn extend(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
2796        self.extensions.insert(key.into(), value.into());
2797    }
2798}
2799
2800#[cfg(all(feature = "std", feature = "serde"))]
2801impl From<ApiError> for ProblemJson {
2802    /// Convert an [`ApiError`] into a `ProblemJson`.
2803    ///
2804    /// - `code` → `type` via [`ErrorCode::urn`]
2805    /// - `request_id` (UUID) → `instance` as `"urn:uuid:<id>"`
2806    /// - `errors` (validation) → `"errors"` extension member
2807    /// - `source` is dropped (not part of the wire format)
2808    ///
2809    /// # Examples
2810    ///
2811    /// ```rust
2812    /// use api_bones::error::{ApiError, ErrorCode, ProblemJson};
2813    ///
2814    /// let err = ApiError::new(ErrorCode::Forbidden, "not allowed");
2815    /// let p = ProblemJson::from(err);
2816    /// assert_eq!(p.status, 403);
2817    /// assert_eq!(p.title, "Forbidden");
2818    /// ```
2819    fn from(err: ApiError) -> Self {
2820        let mut p = Self::new(err.code.urn(), err.title, err.status, err.detail);
2821
2822        #[cfg(feature = "uuid")]
2823        if let Some(id) = err.request_id {
2824            p.instance = Some(format!("urn:uuid:{id}"));
2825        }
2826
2827        if !err.errors.is_empty() {
2828            let errs =
2829                serde_json::to_value(&err.errors).unwrap_or(serde_json::Value::Array(vec![]));
2830            p.extensions.insert("errors".into(), errs);
2831        }
2832
2833        if let Some(info) = err.rate_limit
2834            && let Ok(v) = serde_json::to_value(&info)
2835        {
2836            p.extensions.insert("rate_limit".into(), v);
2837        }
2838
2839        if !err.causes.is_empty() {
2840            let causes: Vec<serde_json::Value> = err
2841                .causes
2842                .into_iter()
2843                .map(|c| {
2844                    let cp = Self::from(c);
2845                    serde_json::to_value(cp).unwrap_or(serde_json::Value::Null)
2846                })
2847                .collect();
2848            p.extensions
2849                .insert("causes".into(), serde_json::Value::Array(causes));
2850        }
2851
2852        // Merge caller-provided extensions last (they may intentionally
2853        // override the generated members above).
2854        for (k, v) in err.extensions {
2855            p.extensions.insert(k, v);
2856        }
2857
2858        p
2859    }
2860}
2861
2862#[cfg(all(feature = "std", feature = "serde", test))]
2863mod problem_json_tests {
2864    use super::*;
2865
2866    #[test]
2867    fn new_sets_fields_and_empty_extensions() {
2868        let p = ProblemJson::new(
2869            "urn:api-bones:error:bad-request",
2870            "Bad Request",
2871            400,
2872            "missing email",
2873        );
2874        assert_eq!(p.r#type, "urn:api-bones:error:bad-request");
2875        assert_eq!(p.title, "Bad Request");
2876        assert_eq!(p.status, 400);
2877        assert_eq!(p.detail, "missing email");
2878        assert!(p.instance.is_none());
2879        assert!(p.extensions.is_empty());
2880    }
2881
2882    #[test]
2883    fn with_instance_sets_instance() {
2884        let p = ProblemJson::new("urn:t", "T", 400, "d")
2885            .with_instance("urn:uuid:00000000-0000-0000-0000-000000000000");
2886        assert_eq!(
2887            p.instance.as_deref(),
2888            Some("urn:uuid:00000000-0000-0000-0000-000000000000")
2889        );
2890    }
2891
2892    #[test]
2893    fn extend_inserts_entry() {
2894        let mut p = ProblemJson::new("urn:t", "T", 400, "d");
2895        p.extend("trace_id", "abc123");
2896        assert_eq!(p.extensions["trace_id"], "abc123");
2897    }
2898
2899    #[test]
2900    fn from_api_error_maps_standard_fields() {
2901        #[cfg(feature = "std")]
2902        let _ = super::super::error_type_mode(); // ensure mode initialised
2903        let err = ApiError::new(ErrorCode::Forbidden, "not allowed");
2904        let p = ProblemJson::from(err);
2905        assert_eq!(p.status, 403);
2906        assert_eq!(p.title, "Forbidden");
2907        assert_eq!(p.detail, "not allowed");
2908    }
2909
2910    #[test]
2911    fn from_api_error_maps_rate_limit_to_extension() {
2912        use crate::ratelimit::RateLimitInfo;
2913        let info = RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(30);
2914        let err = ApiError::rate_limited_with(info);
2915        let p = ProblemJson::from(err);
2916        assert!(p.extensions.contains_key("rate_limit"));
2917        let rl = &p.extensions["rate_limit"];
2918        assert_eq!(rl["limit"], 100);
2919        assert_eq!(rl["remaining"], 0);
2920        assert_eq!(rl["reset"], 1_700_000_000_u64);
2921        assert_eq!(rl["retry_after"], 30);
2922    }
2923
2924    #[test]
2925    fn api_error_rate_limit_serializes_inline() {
2926        use crate::ratelimit::RateLimitInfo;
2927        let err = ApiError::rate_limited(60)
2928            .with_rate_limit(RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(60));
2929        let json = serde_json::to_value(&err).unwrap();
2930        assert_eq!(json["rate_limit"]["limit"], 100);
2931        assert_eq!(json["rate_limit"]["retry_after"], 60);
2932    }
2933
2934    #[test]
2935    fn api_error_rate_limit_omitted_when_none() {
2936        let err = ApiError::bad_request("x");
2937        let json = serde_json::to_value(&err).unwrap();
2938        assert!(json.get("rate_limit").is_none());
2939    }
2940
2941    #[test]
2942    fn from_api_error_maps_validation_errors_to_extension() {
2943        let err = ApiError::new(ErrorCode::ValidationFailed, "bad input").with_errors(vec![
2944            ValidationError {
2945                field: "/email".into(),
2946                message: "invalid".into(),
2947                rule: None,
2948            },
2949        ]);
2950        let p = ProblemJson::from(err);
2951        assert!(p.extensions.contains_key("errors"));
2952        let errs = p.extensions["errors"].as_array().unwrap();
2953        assert_eq!(errs.len(), 1);
2954        assert_eq!(errs[0]["field"], "/email");
2955    }
2956
2957    #[cfg(feature = "uuid")]
2958    #[test]
2959    fn from_api_error_maps_request_id_to_instance() {
2960        let id = uuid::Uuid::nil();
2961        let err = ApiError::new(ErrorCode::BadRequest, "x").with_request_id(id);
2962        let p = ProblemJson::from(err);
2963        assert_eq!(
2964            p.instance.as_deref(),
2965            Some("urn:uuid:00000000-0000-0000-0000-000000000000")
2966        );
2967    }
2968
2969    #[test]
2970    fn serializes_extensions_flat() {
2971        let mut p = ProblemJson::new("urn:t", "T", 400, "d");
2972        p.extend("trace_id", "xyz");
2973        let json: serde_json::Value =
2974            serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
2975        // extension must appear at top level, not nested
2976        assert_eq!(json["trace_id"], "xyz");
2977        assert!(json.get("extensions").is_none());
2978    }
2979
2980    #[test]
2981    fn instance_omitted_when_none() {
2982        let p = ProblemJson::new("urn:t", "T", 400, "d");
2983        let json: serde_json::Value =
2984            serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
2985        assert!(json.get("instance").is_none());
2986    }
2987}
2988
2989// ---------------------------------------------------------------------------
2990// Axum IntoResponse integration
2991// ---------------------------------------------------------------------------
2992
2993#[cfg(feature = "axum")]
2994mod axum_impl {
2995    use super::ApiError;
2996    use axum::response::{IntoResponse, Response};
2997    use http::{HeaderValue, StatusCode};
2998
2999    impl IntoResponse for ApiError {
3000        fn into_response(self) -> Response {
3001            let status =
3002                StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
3003            // ApiError contains only String/u16/Vec<String> fields — serialization
3004            // cannot fail, so expect() is safe here and avoids a dead branch.
3005            let body = serde_json::to_string(&self).expect("ApiError serialization is infallible");
3006
3007            let mut response = (status, body).into_response();
3008            response.headers_mut().insert(
3009                http::header::CONTENT_TYPE,
3010                HeaderValue::from_static("application/problem+json"),
3011            );
3012            response
3013        }
3014    }
3015
3016    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
3017    impl IntoResponse for super::ProblemJson {
3018        fn into_response(self) -> Response {
3019            let status =
3020                StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
3021            let body =
3022                serde_json::to_string(&self).expect("ProblemJson serialization is infallible");
3023            let mut response = (status, body).into_response();
3024            response.headers_mut().insert(
3025                http::header::CONTENT_TYPE,
3026                HeaderValue::from_static("application/problem+json"),
3027            );
3028            response
3029        }
3030    }
3031}
3032
3033#[cfg(all(test, feature = "axum"))]
3034mod axum_tests {
3035    use super::*;
3036    use axum::response::IntoResponse;
3037    use http::StatusCode;
3038
3039    #[tokio::test]
3040    async fn into_response_status_and_content_type() {
3041        reset_error_type_mode();
3042        let err = ApiError::not_found("thing 42 not found");
3043        let response = err.into_response();
3044        assert_eq!(response.status(), StatusCode::NOT_FOUND);
3045        assert_eq!(
3046            response.headers().get("content-type").unwrap(),
3047            "application/problem+json"
3048        );
3049    }
3050
3051    #[tokio::test]
3052    async fn into_response_body() {
3053        reset_error_type_mode();
3054        let err = ApiError::unauthorized("bad token");
3055        let response = err.into_response();
3056        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3057            .await
3058            .unwrap();
3059        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3060        assert_eq!(json["type"], "urn:api-bones:error:unauthorized");
3061        assert_eq!(json["status"], 401);
3062        assert_eq!(json["detail"], "bad token");
3063    }
3064
3065    #[cfg(feature = "utoipa")]
3066    #[test]
3067    fn error_code_schema_is_string_type() {
3068        use utoipa::PartialSchema as _;
3069        use utoipa::openapi::schema::Schema;
3070
3071        let schema_ref = ErrorCode::schema();
3072        let schema = match schema_ref {
3073            utoipa::openapi::RefOr::T(s) => s,
3074            utoipa::openapi::RefOr::Ref(_) => panic!("expected inline schema"),
3075        };
3076        assert!(
3077            matches!(schema, Schema::Object(_)),
3078            "ErrorCode schema should be an object (string type)"
3079        );
3080    }
3081
3082    #[cfg(feature = "utoipa")]
3083    #[test]
3084    fn error_code_schema_name() {
3085        use utoipa::ToSchema as _;
3086        assert_eq!(ErrorCode::name(), "ErrorCode");
3087    }
3088
3089    #[cfg(feature = "serde")]
3090    #[tokio::test]
3091    async fn problem_json_into_response_status_and_content_type() {
3092        use super::ProblemJson;
3093        let p = ProblemJson::new("urn:api-bones:error:not-found", "Not Found", 404, "gone");
3094        let response = p.into_response();
3095        assert_eq!(response.status(), StatusCode::NOT_FOUND);
3096        assert_eq!(
3097            response.headers().get("content-type").unwrap(),
3098            "application/problem+json"
3099        );
3100    }
3101
3102    #[cfg(feature = "serde")]
3103    #[tokio::test]
3104    async fn problem_json_into_response_body_with_extension() {
3105        use super::ProblemJson;
3106        let mut p = ProblemJson::new(
3107            "urn:api-bones:error:bad-request",
3108            "Bad Request",
3109            400,
3110            "missing field",
3111        );
3112        p.extend("trace_id", "abc123");
3113        let response = p.into_response();
3114        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3115            .await
3116            .unwrap();
3117        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3118        assert_eq!(json["type"], "urn:api-bones:error:bad-request");
3119        assert_eq!(json["status"], 400);
3120        assert_eq!(json["trace_id"], "abc123");
3121        assert!(json.get("extensions").is_none());
3122    }
3123
3124    #[cfg(feature = "serde")]
3125    #[tokio::test]
3126    async fn problem_json_instance_omitted_when_none() {
3127        use super::ProblemJson;
3128        let p = ProblemJson::new("urn:t", "T", 500, "d");
3129        let response = p.into_response();
3130        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3131            .await
3132            .unwrap();
3133        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3134        assert!(json.get("instance").is_none());
3135    }
3136
3137    // -----------------------------------------------------------------------
3138    // rate_limited_with — None retry_after branch
3139    // -----------------------------------------------------------------------
3140
3141    #[test]
3142    fn rate_limited_with_no_retry_after() {
3143        use crate::ratelimit::RateLimitInfo;
3144        let info = RateLimitInfo::new(100, 5, 1_700_000_000);
3145        let err = ApiError::rate_limited_with(info);
3146        assert_eq!(err.status, 429);
3147        assert_eq!(err.detail, "Rate limited");
3148        assert!(err.rate_limit.is_some());
3149    }
3150}