Skip to main content

toolkit_canonical_errors/
problem.rs

1use serde::{Deserialize, Serialize};
2
3// `GTS_ID_PREFIX` is the compile-time configured GTS identifier prefix
4// (overridable via the `GTS_ID_PREFIX` env var at build
5// time). Used to assemble the canonical error type id prefix without
6// hard-coding the literal prefix.
7use toolkit_gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX, gts_id, gts_uri};
8
9use crate::context::{
10    Aborted, AlreadyExists, Cancelled, DataLoss, DeadlineExceeded, FailedPrecondition, Internal,
11    InvalidArgument, NotFound, OutOfRange, PermissionDenied, ResourceExhausted, ServiceUnavailable,
12    Unauthenticated, Unimplemented, Unknown,
13};
14use crate::error::CanonicalError;
15use crate::transport::TransportOverrides;
16
17/// Media type for RFC 9457 `application/problem+json` responses.
18pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
19
20// ---------------------------------------------------------------------------
21// ProblemCategory — canonical-category selector for typed contract errors.
22// ---------------------------------------------------------------------------
23
24/// One of the 16 canonical AIP-193 categories. Mirrors [`CanonicalError`]
25/// variants for the purpose of building a [`Problem`] envelope from a
26/// typed contract error (PRD #1536 `#[derive(ContractError)]`) without
27/// requiring the SDK author to construct a full `CanonicalError`
28/// (which requires per-category context payloads).
29///
30/// HTTP status and GTS URI are determined entirely by the category; the
31/// contract error supplies `error_code` / `error_domain` extensions plus a
32/// JSON payload in `context["data"]`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[non_exhaustive]
35pub enum ProblemCategory {
36    Cancelled,
37    Unknown,
38    InvalidArgument,
39    DeadlineExceeded,
40    NotFound,
41    AlreadyExists,
42    PermissionDenied,
43    ResourceExhausted,
44    FailedPrecondition,
45    Aborted,
46    OutOfRange,
47    Unimplemented,
48    Internal,
49    ServiceUnavailable,
50    DataLoss,
51    Unauthenticated,
52}
53
54impl ProblemCategory {
55    /// GTS URI fragment (without the `gts://` scheme). Identical to the
56    /// fragment emitted by [`CanonicalError::gts_type`] for the matching
57    /// variant.
58    #[must_use]
59    pub fn gts_fragment(self) -> &'static str {
60        match self {
61            Self::Cancelled => gts_id!("cf.core.errors.err.v1~cf.core.err.cancelled.v1~"),
62            Self::Unknown => gts_id!("cf.core.errors.err.v1~cf.core.err.unknown.v1~"),
63            Self::InvalidArgument => {
64                gts_id!("cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~")
65            }
66            Self::DeadlineExceeded => {
67                gts_id!("cf.core.errors.err.v1~cf.core.err.deadline_exceeded.v1~")
68            }
69            Self::NotFound => gts_id!("cf.core.errors.err.v1~cf.core.err.not_found.v1~"),
70            Self::AlreadyExists => {
71                gts_id!("cf.core.errors.err.v1~cf.core.err.already_exists.v1~")
72            }
73            Self::PermissionDenied => {
74                gts_id!("cf.core.errors.err.v1~cf.core.err.permission_denied.v1~")
75            }
76            Self::ResourceExhausted => {
77                gts_id!("cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~")
78            }
79            Self::FailedPrecondition => {
80                gts_id!("cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~")
81            }
82            Self::Aborted => gts_id!("cf.core.errors.err.v1~cf.core.err.aborted.v1~"),
83            Self::OutOfRange => gts_id!("cf.core.errors.err.v1~cf.core.err.out_of_range.v1~"),
84            Self::Unimplemented => {
85                gts_id!("cf.core.errors.err.v1~cf.core.err.unimplemented.v1~")
86            }
87            Self::Internal => gts_id!("cf.core.errors.err.v1~cf.core.err.internal.v1~"),
88            Self::ServiceUnavailable => {
89                gts_id!("cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~")
90            }
91            Self::DataLoss => gts_id!("cf.core.errors.err.v1~cf.core.err.data_loss.v1~"),
92            Self::Unauthenticated => {
93                gts_id!("cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~")
94            }
95        }
96    }
97
98    /// HTTP status mapping per AIP-193 and gRPC↔HTTP conventions.
99    #[must_use]
100    #[allow(
101        clippy::match_same_arms,
102        reason = "each canonical category is mapped explicitly per AIP-193; collapsing arms whose codes happen to coincide today would silently hide a future schema mismatch."
103    )]
104    pub fn http_status(self) -> u16 {
105        match self {
106            Self::Cancelled => 499,
107            Self::Unknown => 500,
108            Self::InvalidArgument => 400,
109            Self::DeadlineExceeded => 504,
110            Self::NotFound => 404,
111            Self::AlreadyExists => 409,
112            Self::PermissionDenied => 403,
113            Self::ResourceExhausted => 429,
114            Self::FailedPrecondition => 400,
115            Self::Aborted => 409,
116            Self::OutOfRange => 400,
117            Self::Unimplemented => 501,
118            Self::Internal => 500,
119            Self::ServiceUnavailable => 503,
120            Self::DataLoss => 500,
121            Self::Unauthenticated => 401,
122        }
123    }
124
125    /// Human-readable title for the RFC 9457 envelope. Same string as
126    /// [`CanonicalError::title`] for the matching variant.
127    #[must_use]
128    pub fn title(self) -> &'static str {
129        match self {
130            Self::Cancelled => "Cancelled",
131            Self::Unknown => "Unknown",
132            Self::InvalidArgument => "Invalid argument",
133            Self::DeadlineExceeded => "Deadline exceeded",
134            Self::NotFound => "Not found",
135            Self::AlreadyExists => "Already exists",
136            Self::PermissionDenied => "Permission denied",
137            Self::ResourceExhausted => "Resource exhausted",
138            Self::FailedPrecondition => "Failed precondition",
139            Self::Aborted => "Aborted",
140            Self::OutOfRange => "Out of range",
141            Self::Unimplemented => "Unimplemented",
142            Self::Internal => "Internal",
143            Self::ServiceUnavailable => "Service unavailable",
144            Self::DataLoss => "Data loss",
145            Self::Unauthenticated => "Unauthenticated",
146        }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Problem (RFC 9457)
152// ---------------------------------------------------------------------------
153
154/// RFC 9457 §3.1.1's own designated default for `type` when a Problem
155/// carries no more specific type than its HTTP status code.
156fn default_problem_type() -> String {
157    "about:blank".to_owned()
158}
159
160/// Deserialize default for `context` when a foreign peer's Problem omits it.
161/// `serde_json::Value`'s own `Default` is `Value::Null`, which is wrong here:
162/// `TryFrom<Problem>` dispatches a category's context type by deserializing
163/// this value, and context-free categories (`NotFoundV1 {}` and similar) fail
164/// to deserialize from `null` ("invalid type: null, expected struct") but
165/// succeed from an empty object - `{}` is the value that actually round-trips.
166fn default_context() -> serde_json::Value {
167    serde_json::Value::Object(serde_json::Map::new())
168}
169
170/// RFC 9457 §3.1 makes every member but this struct's Rust type reflects
171/// optional on the wire; `status` is `Option<u16>` rather than a defaulted
172/// `u16` since a synthetic `0` would be an actively wrong status, not an
173/// honest "wasn't there" (same reasoning as `default_context`, below).
174/// Callers that have a real status to fall back to normalize `None` after
175/// parsing (`enrich_problem_response`, `map_http_error`); SSE has none and
176/// preserves the absence. `Serialize` is unaffected - `from_error` always
177/// populates every field, so wire output is unchanged.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Problem {
180    #[serde(rename = "type", default = "default_problem_type")]
181    pub problem_type: String,
182    #[serde(default)]
183    pub title: String,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub status: Option<u16>,
186    #[serde(default)]
187    pub detail: String,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub instance: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub trace_id: Option<String>,
192    #[serde(default = "default_context")]
193    pub context: serde_json::Value,
194
195    /// Machine-readable identifier of the typed error variant inside its
196    /// domain. Set by [`#[derive(ContractError)]`] when a contract error
197    /// crosses the wire so PRD-conformant peers can reconstruct the
198    /// original Rust enum variant via `error_code` + `error_domain`.
199    /// `None` for canonical-category-only errors.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub error_code: Option<String>,
202
203    /// Namespace owning the `error_code`. Conventionally
204    /// `<service>.<version>` (e.g. `billing.v1`). `None` when no contract
205    /// error is in play.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub error_domain: Option<String>,
208}
209
210impl Problem {
211    /// Convert a `CanonicalError` to a `Problem`.
212    ///
213    /// # Errors
214    ///
215    /// Returns `serde_json::Error` if the error-category context type
216    /// fails to serialize.  Built-in context types are plain structs and
217    /// should never fail, but this keeps the failure visible rather than
218    /// silently producing an empty `"context": {}`.
219    pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
220        let problem_type = gts_uri!(err.gts_type());
221        let title = err.title().to_owned();
222        let status = err.status_code();
223        let detail = err.detail().to_owned();
224
225        let mut context = serialize_context(err)?;
226
227        if let Some(rt) = err.resource_type() {
228            context["resource_type"] = serde_json::Value::String(rt.to_owned());
229        }
230
231        if let Some(rn) = err.resource_name() {
232            context["resource_name"] = serde_json::Value::String(rn.to_owned());
233        }
234
235        Ok(Problem {
236            problem_type,
237            title,
238            status: Some(status),
239            detail,
240            instance: None,
241            trace_id: None,
242            context,
243            error_code: None,
244            error_domain: None,
245        })
246    }
247
248    /// Attach the `error_code` extension field (PRD #1536 contract-error
249    /// envelope). Returns `self` for chaining.
250    #[must_use]
251    pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
252        self.error_code = Some(code.into());
253        self
254    }
255
256    /// Attach the `error_domain` extension field. Returns `self` for chaining.
257    #[must_use]
258    pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
259        self.error_domain = Some(domain.into());
260        self
261    }
262
263    /// Build a [`Problem`] for a typed contract error (PRD #1536 envelope).
264    ///
265    /// `category` selects one of the 16 canonical AIP-193 categories; the
266    /// resulting `Problem` carries the matching GTS URI in `type`, the
267    /// canonical HTTP status, and the canonical title. `error_code` and
268    /// `error_domain` populate the PRD extension fields, and `data` is
269    /// placed at `context["data"]` to carry variant-specific payload.
270    ///
271    /// Used by `#[derive(ContractError)]` emit-paths; SDK authors rarely
272    /// call this directly.
273    pub fn contract_error(
274        category: ProblemCategory,
275        error_code: impl Into<String>,
276        error_domain: impl Into<String>,
277        detail: impl Into<String>,
278        data: serde_json::Value,
279    ) -> Self {
280        let mut context = serde_json::Map::new();
281        context.insert("data".to_owned(), data);
282        Problem {
283            problem_type: format!("gts://{}", category.gts_fragment()),
284            title: category.title().to_owned(),
285            status: Some(category.http_status()),
286            detail: detail.into(),
287            instance: None,
288            trace_id: None,
289            context: serde_json::Value::Object(context),
290            error_code: Some(error_code.into()),
291            error_domain: Some(error_domain.into()),
292        }
293    }
294
295    /// Convert a `CanonicalError` to a `Problem`, including the internal
296    /// diagnostic string in the `context` for `Internal` and `Unknown`
297    /// variants.
298    ///
299    /// **This method MUST NOT be used in production.** It exists so that
300    /// development and test environments can surface the real error cause
301    /// in the wire response for easier debugging.
302    ///
303    /// In production, use [`from_error`](Self::from_error) instead — it
304    /// never leaks the diagnostic string.
305    ///
306    /// Available only when the `debug-problem` feature is enabled — intended for
307    /// local development. Enabling this in production leaks diagnostic detail
308    /// onto the wire.
309    ///
310    /// # Errors
311    ///
312    /// Returns `serde_json::Error` if the context fails to serialize.
313    #[cfg(feature = "debug-problem")]
314    pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
315        let mut problem = Self::from_error(err)?;
316
317        if let Some(diag) = err.diagnostic() {
318            problem.context["description"] = serde_json::Value::String(diag.to_owned());
319        }
320
321        Ok(problem)
322    }
323
324    /// Set the `trace_id` field, returning `self` for chaining.
325    #[must_use]
326    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
327        self.trace_id = Some(trace_id.into());
328        self
329    }
330
331    /// Set the `instance` field, returning `self` for chaining.
332    #[must_use]
333    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
334        self.instance = Some(instance.into());
335        self
336    }
337}
338
339fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
340    match err {
341        CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
342        CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
343        CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
344        CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
345        CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
346        CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
347        CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
348        CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
349        CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
350        CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
351        CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
352        CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
353        CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
354        CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
355        CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
356        CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
357    }
358}
359
360// `Problem.context` must be a JSON object per the OpenAPI schema, so we wrap
361// the serialization error in `{ "serialization_error": ... }`. The original
362// CanonicalError is already preserved in the other Problem fields.
363#[allow(unknown_lints, de1302_error_from_to_string)]
364impl From<CanonicalError> for Problem {
365    fn from(err: CanonicalError) -> Self {
366        match Problem::from_error(&err) {
367            Ok(p) => p,
368            Err(ser_err) => Problem {
369                problem_type: gts_uri!(err.gts_type()),
370                title: err.title().to_owned(),
371                status: Some(err.status_code()),
372                detail: err.detail().to_owned(),
373                instance: None,
374                trace_id: None,
375                context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
376                error_code: None,
377                error_domain: None,
378            },
379        }
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Round-trip: Problem → CanonicalError
385//
386// Reverse direction of `From<CanonicalError> for Problem`. Out-of-process SDK
387// consumers receive `application/problem+json` over the wire, deserialize into
388// `Problem`, and reconstruct the typed `CanonicalError` via this `TryFrom`.
389// In-process consumers do not need this hop — they hold `CanonicalError`
390// directly from the ClientHub call.
391//
392// Lossy by design:
393// * `Internal.description` and `Unknown.description` are `#[serde(skip)]` on
394//   the wire, so they reconstruct as empty strings. This is intentional —
395//   production wire responses never carry the server-side diagnostic.
396// * Transport fields (`instance`, `trace_id`) live on `Problem`, not on
397//   `CanonicalError`. Callers that need them should read them off the
398//   `Problem` before converting.
399// ---------------------------------------------------------------------------
400
401/// Prefix on `Problem.problem_type` produced by the forward conversion.
402const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
403
404/// Reasons a `Problem` cannot be reconstructed as a `CanonicalError`.
405#[derive(Debug, thiserror::Error)]
406pub enum ProblemConversionError {
407    /// The `problem_type` URI does not match any of the 16 canonical
408    /// category identifiers. Either the server emitted a non-canonical
409    /// problem or the wire format has drifted.
410    #[error("unrecognized problem_type: {0}")]
411    UnknownProblemType(String),
412
413    /// The `context` payload could not be deserialized into the context
414    /// type for the matched category. The category and underlying serde
415    /// error are surfaced for diagnostics.
416    #[error("invalid context for canonical category {category}: {source}")]
417    InvalidContext {
418        category: &'static str,
419        #[source]
420        source: serde_json::Error,
421    },
422
423    /// The wire `status` is not a valid HTTP status code (RFC 9110: a
424    /// three-digit integer in the 100-599 range). Rejected here rather than
425    /// silently stored as an override, since `TryFrom<Problem>` is the
426    /// crate's one boundary for untrusted/out-of-process input.
427    #[error("invalid HTTP status in Problem: {0}")]
428    InvalidStatus(u16),
429
430    /// The wire `status` is syntactically valid but is in a different HTTP
431    /// status-code class than the matched category's default, e.g. an
432    /// `invalid_argument` (4xx) `Problem` carrying `status: 500`. Flipping
433    /// between client-fault and server-fault semantics changes client retry
434    /// behavior, so this is rejected rather than accepted as an override.
435    #[error("status {status} is a different HTTP status class than category {category}'s default")]
436    CategoryStatusMismatch { category: &'static str, status: u16 },
437}
438
439/// Prefix of every canonical GTS identifier. Stripped to expose the category
440/// name (e.g. `cancelled`, `invalid_argument`). Not a complete GTS string by
441/// itself — only the concatenation `{prefix}{category}{suffix}` is a valid
442/// GTS identifier.
443#[allow(unknown_lints, de0901_gts_string_pattern)]
444/// Prefix of every canonical GTS error type id. Built by concatenating the
445/// configured GTS ID prefix (overridable via `GTS_ID_PREFIX`
446/// at compile time) with the fixed "cf.core.errors.err.v1~cf.core.err."
447/// suffix. This is *not* a complete GTS id (the trailing per-error token is
448/// appended at runtime in `CanonicalError::gts_type`), so `gts_id!` (which
449/// validates a full id at macro-expansion time) is not applicable here.
450/// `concat!` also cannot be used since it only accepts literals; the value
451/// is therefore materialised once via a `OnceLock` and exposed as a
452/// `&'static str`.
453fn gts_type_prefix() -> &'static str {
454    use std::sync::OnceLock;
455    static PREFIX: OnceLock<String> = OnceLock::new();
456    PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
457}
458/// Suffix of every canonical GTS identifier. See [`gts_type_prefix`].
459const GTS_TYPE_SUFFIX: &str = ".v1~";
460
461/// Strip the canonical problem-type URI down to
462/// `<category>`. Returns `None` if the URI doesn't match the canonical shape.
463fn category_from_problem_type(problem_type: &str) -> Option<&str> {
464    let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
465    let after_prefix = rest.strip_prefix(gts_type_prefix())?;
466    after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
467}
468
469/// Extract `resource_type` and `resource_name` from the `Problem.context`
470/// JSON, returning the pair (both `None` if absent). The forward conversion
471/// stamps these as plain string fields alongside the category-specific
472/// payload; here we read them back without disturbing the serde
473/// deserialization of the category context (serde ignores unknown fields).
474fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
475    let resource_type = context
476        .get("resource_type")
477        .and_then(serde_json::Value::as_str)
478        .map(str::to_owned);
479    let resource_name = context
480        .get("resource_name")
481        .and_then(serde_json::Value::as_str)
482        .map(str::to_owned);
483    (resource_type, resource_name)
484}
485
486fn deserialize_ctx<T>(
487    category: &'static str,
488    context: serde_json::Value,
489) -> Result<T, ProblemConversionError>
490where
491    T: serde::de::DeserializeOwned,
492{
493    serde_json::from_value(context)
494        .map_err(|source| ProblemConversionError::InvalidContext { category, source })
495}
496
497impl TryFrom<Problem> for CanonicalError {
498    type Error = ProblemConversionError;
499
500    fn try_from(problem: Problem) -> Result<Self, Self::Error> {
501        let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
502            ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
503        })?;
504
505        let detail = problem.detail;
506        let (resource_type, resource_name) = extract_resource_fields(&problem.context);
507        let ctx_value = problem.context;
508
509        let mut canonical = match category {
510            "cancelled" => Self::Cancelled {
511                ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
512                detail,
513                resource_type,
514                resource_name,
515                overrides: TransportOverrides::default(),
516            },
517            "unknown" => Self::Unknown {
518                ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
519                detail,
520                resource_type,
521                resource_name,
522                overrides: TransportOverrides::default(),
523            },
524            "invalid_argument" => Self::InvalidArgument {
525                ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
526                detail,
527                resource_type,
528                resource_name,
529                overrides: TransportOverrides::default(),
530            },
531            "deadline_exceeded" => Self::DeadlineExceeded {
532                ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
533                detail,
534                resource_type,
535                resource_name,
536                overrides: TransportOverrides::default(),
537            },
538            "not_found" => Self::NotFound {
539                ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
540                detail,
541                resource_type,
542                resource_name,
543                overrides: TransportOverrides::default(),
544            },
545            "already_exists" => Self::AlreadyExists {
546                ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
547                detail,
548                resource_type,
549                resource_name,
550                overrides: TransportOverrides::default(),
551            },
552            "permission_denied" => Self::PermissionDenied {
553                ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
554                detail,
555                resource_type,
556                resource_name,
557                overrides: TransportOverrides::default(),
558            },
559            "resource_exhausted" => Self::ResourceExhausted {
560                ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
561                detail,
562                resource_type,
563                resource_name,
564                overrides: TransportOverrides::default(),
565            },
566            "failed_precondition" => Self::FailedPrecondition {
567                ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
568                detail,
569                resource_type,
570                resource_name,
571                overrides: TransportOverrides::default(),
572            },
573            "aborted" => Self::Aborted {
574                ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
575                detail,
576                resource_type,
577                resource_name,
578                overrides: TransportOverrides::default(),
579            },
580            "out_of_range" => Self::OutOfRange {
581                ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
582                detail,
583                resource_type,
584                resource_name,
585                overrides: TransportOverrides::default(),
586            },
587            "unimplemented" => Self::Unimplemented {
588                ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
589                detail,
590                resource_type,
591                resource_name,
592                overrides: TransportOverrides::default(),
593            },
594            "internal" => Self::Internal {
595                // `Internal.description` is `#[serde(skip)]`; the wire
596                // never carries it, so it reconstructs as an empty string.
597                ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
598                detail,
599                overrides: TransportOverrides::default(),
600            },
601            "service_unavailable" => Self::ServiceUnavailable {
602                ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
603                detail,
604                resource_type,
605                resource_name,
606                overrides: TransportOverrides::default(),
607            },
608            "data_loss" => Self::DataLoss {
609                ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
610                detail,
611                resource_type,
612                resource_name,
613                overrides: TransportOverrides::default(),
614            },
615            "unauthenticated" => Self::Unauthenticated {
616                ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
617                detail,
618                resource_type,
619                resource_name,
620                overrides: TransportOverrides::default(),
621            },
622            _ => {
623                return Err(ProblemConversionError::UnknownProblemType(
624                    problem.problem_type,
625                ));
626            }
627        };
628
629        // A genuinely absent `status` isn't an error - `canonical` just
630        // keeps the category's own default status untouched.
631        if let Some(status) = problem.status {
632            if !(100..=599).contains(&status) {
633                return Err(ProblemConversionError::InvalidStatus(status));
634            }
635
636            if !canonical.is_same_status_class(status) {
637                return Err(ProblemConversionError::CategoryStatusMismatch {
638                    category: canonical.category_name(),
639                    status,
640                });
641            }
642
643            // Recover any transport override purely from the wire `status`
644            // — no additional field on `Problem` is needed since the
645            // category's default status is already known
646            // (`default_http_status`).
647            if status != canonical.default_http_status() {
648                canonical.transport_overrides_mut().http_status = Some(status);
649            }
650        }
651
652        Ok(canonical)
653    }
654}
655
656// ---------------------------------------------------------------------------
657// axum integration (feature = "axum")
658// ---------------------------------------------------------------------------
659
660#[cfg(feature = "axum")]
661fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
662    if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
663        return None;
664    }
665
666    ServiceUnavailable::deserialize(&problem.context)
667        .ok()?
668        .retry_after_seconds
669}
670
671#[cfg(feature = "axum")]
672impl axum::response::IntoResponse for Problem {
673    fn into_response(self) -> axum::response::Response {
674        match serde_json::to_vec(&self) {
675            Ok(body) => {
676                let status = self
677                    .status
678                    .and_then(|s| http::StatusCode::from_u16(s).ok())
679                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
680                let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
681                let mut response = (
682                    status,
683                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
684                    body,
685                )
686                    .into_response();
687
688                if let Some(seconds) = retry_after_seconds {
689                    response
690                        .headers_mut()
691                        .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
692                }
693
694                response
695            }
696            Err(e) => {
697                tracing::error!(
698                    error = %e,
699                    problem_type = %self.problem_type,
700                    status = ?self.status,
701                    "failed to serialize Problem; emitting fallback body",
702                );
703                let body = format!(
704                    r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
705                    PROBLEM_TYPE_PREFIX,
706                    gts_type_prefix(),
707                    GTS_TYPE_SUFFIX
708                );
709                (
710                    http::StatusCode::INTERNAL_SERVER_ERROR,
711                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
712                    body,
713                )
714                    .into_response()
715            }
716        }
717    }
718}
719
720#[cfg(feature = "axum")]
721impl axum::response::IntoResponse for CanonicalError {
722    fn into_response(self) -> axum::response::Response {
723        // Stash a clone of self into the response extensions so the canonical
724        // error middleware (DESIGN.md §3.6) can recover `diagnostic()` and log
725        // the unredacted description server-side without putting it on the
726        // wire. The `description` fields on `Internal` / `Unknown` are
727        // `#[serde(skip)]`, so the bytes-roundtrip path alone cannot surface
728        // them.
729        let for_extension = self.clone();
730        let mut response = Problem::from(self).into_response();
731        response.extensions_mut().insert(for_extension);
732        response
733    }
734}
735
736// ---------------------------------------------------------------------------
737// utoipa integration (feature = "utoipa")
738// ---------------------------------------------------------------------------
739
740#[cfg(feature = "utoipa")]
741impl utoipa::PartialSchema for Problem {
742    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
743        use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
744
745        ObjectBuilder::new()
746            .property(
747                "type",
748                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
749            )
750            .required("type")
751            .property(
752                "title",
753                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
754            )
755            .required("title")
756            .property(
757                "status",
758                ObjectBuilder::new()
759                    .schema_type(SchemaType::Type(Type::Integer))
760                    .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
761            )
762            .required("status")
763            .property(
764                "detail",
765                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
766            )
767            .required("detail")
768            .property(
769                "instance",
770                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
771            )
772            .property(
773                "trace_id",
774                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
775            )
776            .property(
777                "context",
778                ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
779            )
780            .required("context")
781            .description(Some(
782                "RFC 9457 problem+json. `context` varies by error category.",
783            ))
784            .into()
785    }
786}
787
788#[cfg(feature = "utoipa")]
789impl utoipa::ToSchema for Problem {
790    fn name() -> std::borrow::Cow<'static, str> {
791        std::borrow::Cow::Borrowed("Problem")
792    }
793}
794
795#[cfg(test)]
796#[cfg_attr(coverage_nightly, coverage(off))]
797#[path = "problem_tests.rs"]
798mod tests;