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#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct Problem {
156    #[serde(rename = "type")]
157    pub problem_type: String,
158    pub title: String,
159    pub status: u16,
160    pub detail: String,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub instance: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub trace_id: Option<String>,
165    pub context: serde_json::Value,
166
167    /// Machine-readable identifier of the typed error variant inside its
168    /// domain. Set by [`#[derive(ContractError)]`] when a contract error
169    /// crosses the wire so PRD-conformant peers can reconstruct the
170    /// original Rust enum variant via `error_code` + `error_domain`.
171    /// `None` for canonical-category-only errors.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub error_code: Option<String>,
174
175    /// Namespace owning the `error_code`. Conventionally
176    /// `<service>.<version>` (e.g. `billing.v1`). `None` when no contract
177    /// error is in play.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub error_domain: Option<String>,
180}
181
182impl Problem {
183    /// Convert a `CanonicalError` to a `Problem`.
184    ///
185    /// # Errors
186    ///
187    /// Returns `serde_json::Error` if the error-category context type
188    /// fails to serialize.  Built-in context types are plain structs and
189    /// should never fail, but this keeps the failure visible rather than
190    /// silently producing an empty `"context": {}`.
191    pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
192        let problem_type = gts_uri!(err.gts_type());
193        let title = err.title().to_owned();
194        let status = err.status_code();
195        let detail = err.detail().to_owned();
196
197        let mut context = serialize_context(err)?;
198
199        if let Some(rt) = err.resource_type() {
200            context["resource_type"] = serde_json::Value::String(rt.to_owned());
201        }
202
203        if let Some(rn) = err.resource_name() {
204            context["resource_name"] = serde_json::Value::String(rn.to_owned());
205        }
206
207        Ok(Problem {
208            problem_type,
209            title,
210            status,
211            detail,
212            instance: None,
213            trace_id: None,
214            context,
215            error_code: None,
216            error_domain: None,
217        })
218    }
219
220    /// Attach the `error_code` extension field (PRD #1536 contract-error
221    /// envelope). Returns `self` for chaining.
222    #[must_use]
223    pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
224        self.error_code = Some(code.into());
225        self
226    }
227
228    /// Attach the `error_domain` extension field. Returns `self` for chaining.
229    #[must_use]
230    pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
231        self.error_domain = Some(domain.into());
232        self
233    }
234
235    /// Build a [`Problem`] for a typed contract error (PRD #1536 envelope).
236    ///
237    /// `category` selects one of the 16 canonical AIP-193 categories; the
238    /// resulting `Problem` carries the matching GTS URI in `type`, the
239    /// canonical HTTP status, and the canonical title. `error_code` and
240    /// `error_domain` populate the PRD extension fields, and `data` is
241    /// placed at `context["data"]` to carry variant-specific payload.
242    ///
243    /// Used by `#[derive(ContractError)]` emit-paths; SDK authors rarely
244    /// call this directly.
245    pub fn contract_error(
246        category: ProblemCategory,
247        error_code: impl Into<String>,
248        error_domain: impl Into<String>,
249        detail: impl Into<String>,
250        data: serde_json::Value,
251    ) -> Self {
252        let mut context = serde_json::Map::new();
253        context.insert("data".to_owned(), data);
254        Problem {
255            problem_type: format!("gts://{}", category.gts_fragment()),
256            title: category.title().to_owned(),
257            status: category.http_status(),
258            detail: detail.into(),
259            instance: None,
260            trace_id: None,
261            context: serde_json::Value::Object(context),
262            error_code: Some(error_code.into()),
263            error_domain: Some(error_domain.into()),
264        }
265    }
266
267    /// Convert a `CanonicalError` to a `Problem`, including the internal
268    /// diagnostic string in the `context` for `Internal` and `Unknown`
269    /// variants.
270    ///
271    /// **This method MUST NOT be used in production.** It exists so that
272    /// development and test environments can surface the real error cause
273    /// in the wire response for easier debugging.
274    ///
275    /// In production, use [`from_error`](Self::from_error) instead — it
276    /// never leaks the diagnostic string.
277    ///
278    /// Available only when the `debug-problem` feature is enabled — intended for
279    /// local development. Enabling this in production leaks diagnostic detail
280    /// onto the wire.
281    ///
282    /// # Errors
283    ///
284    /// Returns `serde_json::Error` if the context fails to serialize.
285    #[cfg(feature = "debug-problem")]
286    pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
287        let mut problem = Self::from_error(err)?;
288
289        if let Some(diag) = err.diagnostic() {
290            problem.context["description"] = serde_json::Value::String(diag.to_owned());
291        }
292
293        Ok(problem)
294    }
295
296    /// Set the `trace_id` field, returning `self` for chaining.
297    #[must_use]
298    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
299        self.trace_id = Some(trace_id.into());
300        self
301    }
302
303    /// Set the `instance` field, returning `self` for chaining.
304    #[must_use]
305    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
306        self.instance = Some(instance.into());
307        self
308    }
309}
310
311fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
312    match err {
313        CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
314        CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
315        CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
316        CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
317        CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
318        CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
319        CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
320        CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
321        CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
322        CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
323        CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
324        CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
325        CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
326        CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
327        CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
328        CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
329    }
330}
331
332// `Problem.context` must be a JSON object per the OpenAPI schema, so we wrap
333// the serialization error in `{ "serialization_error": ... }`. The original
334// CanonicalError is already preserved in the other Problem fields.
335#[allow(unknown_lints, de1302_error_from_to_string)]
336impl From<CanonicalError> for Problem {
337    fn from(err: CanonicalError) -> Self {
338        match Problem::from_error(&err) {
339            Ok(p) => p,
340            Err(ser_err) => Problem {
341                problem_type: gts_uri!(err.gts_type()),
342                title: err.title().to_owned(),
343                status: err.status_code(),
344                detail: err.detail().to_owned(),
345                instance: None,
346                trace_id: None,
347                context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
348                error_code: None,
349                error_domain: None,
350            },
351        }
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Round-trip: Problem → CanonicalError
357//
358// Reverse direction of `From<CanonicalError> for Problem`. Out-of-process SDK
359// consumers receive `application/problem+json` over the wire, deserialize into
360// `Problem`, and reconstruct the typed `CanonicalError` via this `TryFrom`.
361// In-process consumers do not need this hop — they hold `CanonicalError`
362// directly from the ClientHub call.
363//
364// Lossy by design:
365// * `Internal.description` and `Unknown.description` are `#[serde(skip)]` on
366//   the wire, so they reconstruct as empty strings. This is intentional —
367//   production wire responses never carry the server-side diagnostic.
368// * Transport fields (`instance`, `trace_id`) live on `Problem`, not on
369//   `CanonicalError`. Callers that need them should read them off the
370//   `Problem` before converting.
371// ---------------------------------------------------------------------------
372
373/// Prefix on `Problem.problem_type` produced by the forward conversion.
374const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
375
376/// Reasons a `Problem` cannot be reconstructed as a `CanonicalError`.
377#[derive(Debug, thiserror::Error)]
378pub enum ProblemConversionError {
379    /// The `problem_type` URI does not match any of the 16 canonical
380    /// category identifiers. Either the server emitted a non-canonical
381    /// problem or the wire format has drifted.
382    #[error("unrecognized problem_type: {0}")]
383    UnknownProblemType(String),
384
385    /// The `context` payload could not be deserialized into the context
386    /// type for the matched category. The category and underlying serde
387    /// error are surfaced for diagnostics.
388    #[error("invalid context for canonical category {category}: {source}")]
389    InvalidContext {
390        category: &'static str,
391        #[source]
392        source: serde_json::Error,
393    },
394
395    /// The wire `status` is not a valid HTTP status code (RFC 9110: a
396    /// three-digit integer in the 100-599 range). Rejected here rather than
397    /// silently stored as an override, since `TryFrom<Problem>` is the
398    /// crate's one boundary for untrusted/out-of-process input.
399    #[error("invalid HTTP status in Problem: {0}")]
400    InvalidStatus(u16),
401
402    /// The wire `status` is syntactically valid but is in a different HTTP
403    /// status-code class than the matched category's default, e.g. an
404    /// `invalid_argument` (4xx) `Problem` carrying `status: 500`. Flipping
405    /// between client-fault and server-fault semantics changes client retry
406    /// behavior, so this is rejected rather than accepted as an override.
407    #[error("status {status} is a different HTTP status class than category {category}'s default")]
408    CategoryStatusMismatch { category: &'static str, status: u16 },
409}
410
411/// Prefix of every canonical GTS identifier. Stripped to expose the category
412/// name (e.g. `cancelled`, `invalid_argument`). Not a complete GTS string by
413/// itself — only the concatenation `{prefix}{category}{suffix}` is a valid
414/// GTS identifier.
415#[allow(unknown_lints, de0901_gts_string_pattern)]
416/// Prefix of every canonical GTS error type id. Built by concatenating the
417/// configured GTS ID prefix (overridable via `GTS_ID_PREFIX`
418/// at compile time) with the fixed "cf.core.errors.err.v1~cf.core.err."
419/// suffix. This is *not* a complete GTS id (the trailing per-error token is
420/// appended at runtime in `CanonicalError::gts_type`), so `gts_id!` (which
421/// validates a full id at macro-expansion time) is not applicable here.
422/// `concat!` also cannot be used since it only accepts literals; the value
423/// is therefore materialised once via a `OnceLock` and exposed as a
424/// `&'static str`.
425fn gts_type_prefix() -> &'static str {
426    use std::sync::OnceLock;
427    static PREFIX: OnceLock<String> = OnceLock::new();
428    PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
429}
430/// Suffix of every canonical GTS identifier. See [`gts_type_prefix`].
431const GTS_TYPE_SUFFIX: &str = ".v1~";
432
433/// Strip the canonical problem-type URI down to
434/// `<category>`. Returns `None` if the URI doesn't match the canonical shape.
435fn category_from_problem_type(problem_type: &str) -> Option<&str> {
436    let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
437    let after_prefix = rest.strip_prefix(gts_type_prefix())?;
438    after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
439}
440
441/// Extract `resource_type` and `resource_name` from the `Problem.context`
442/// JSON, returning the pair (both `None` if absent). The forward conversion
443/// stamps these as plain string fields alongside the category-specific
444/// payload; here we read them back without disturbing the serde
445/// deserialization of the category context (serde ignores unknown fields).
446fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
447    let resource_type = context
448        .get("resource_type")
449        .and_then(serde_json::Value::as_str)
450        .map(str::to_owned);
451    let resource_name = context
452        .get("resource_name")
453        .and_then(serde_json::Value::as_str)
454        .map(str::to_owned);
455    (resource_type, resource_name)
456}
457
458fn deserialize_ctx<T>(
459    category: &'static str,
460    context: serde_json::Value,
461) -> Result<T, ProblemConversionError>
462where
463    T: serde::de::DeserializeOwned,
464{
465    serde_json::from_value(context)
466        .map_err(|source| ProblemConversionError::InvalidContext { category, source })
467}
468
469impl TryFrom<Problem> for CanonicalError {
470    type Error = ProblemConversionError;
471
472    fn try_from(problem: Problem) -> Result<Self, Self::Error> {
473        let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
474            ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
475        })?;
476
477        let detail = problem.detail;
478        let (resource_type, resource_name) = extract_resource_fields(&problem.context);
479        let ctx_value = problem.context;
480
481        let mut canonical = match category {
482            "cancelled" => Self::Cancelled {
483                ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
484                detail,
485                resource_type,
486                resource_name,
487                overrides: TransportOverrides::default(),
488            },
489            "unknown" => Self::Unknown {
490                ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
491                detail,
492                resource_type,
493                resource_name,
494                overrides: TransportOverrides::default(),
495            },
496            "invalid_argument" => Self::InvalidArgument {
497                ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
498                detail,
499                resource_type,
500                resource_name,
501                overrides: TransportOverrides::default(),
502            },
503            "deadline_exceeded" => Self::DeadlineExceeded {
504                ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
505                detail,
506                resource_type,
507                resource_name,
508                overrides: TransportOverrides::default(),
509            },
510            "not_found" => Self::NotFound {
511                ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
512                detail,
513                resource_type,
514                resource_name,
515                overrides: TransportOverrides::default(),
516            },
517            "already_exists" => Self::AlreadyExists {
518                ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
519                detail,
520                resource_type,
521                resource_name,
522                overrides: TransportOverrides::default(),
523            },
524            "permission_denied" => Self::PermissionDenied {
525                ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
526                detail,
527                resource_type,
528                resource_name,
529                overrides: TransportOverrides::default(),
530            },
531            "resource_exhausted" => Self::ResourceExhausted {
532                ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
533                detail,
534                resource_type,
535                resource_name,
536                overrides: TransportOverrides::default(),
537            },
538            "failed_precondition" => Self::FailedPrecondition {
539                ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
540                detail,
541                resource_type,
542                resource_name,
543                overrides: TransportOverrides::default(),
544            },
545            "aborted" => Self::Aborted {
546                ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
547                detail,
548                resource_type,
549                resource_name,
550                overrides: TransportOverrides::default(),
551            },
552            "out_of_range" => Self::OutOfRange {
553                ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
554                detail,
555                resource_type,
556                resource_name,
557                overrides: TransportOverrides::default(),
558            },
559            "unimplemented" => Self::Unimplemented {
560                ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
561                detail,
562                resource_type,
563                resource_name,
564                overrides: TransportOverrides::default(),
565            },
566            "internal" => Self::Internal {
567                // `Internal.description` is `#[serde(skip)]`; the wire
568                // never carries it, so it reconstructs as an empty string.
569                ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
570                detail,
571                overrides: TransportOverrides::default(),
572            },
573            "service_unavailable" => Self::ServiceUnavailable {
574                ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
575                detail,
576                resource_type,
577                resource_name,
578                overrides: TransportOverrides::default(),
579            },
580            "data_loss" => Self::DataLoss {
581                ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
582                detail,
583                resource_type,
584                resource_name,
585                overrides: TransportOverrides::default(),
586            },
587            "unauthenticated" => Self::Unauthenticated {
588                ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
589                detail,
590                resource_type,
591                resource_name,
592                overrides: TransportOverrides::default(),
593            },
594            _ => {
595                return Err(ProblemConversionError::UnknownProblemType(
596                    problem.problem_type,
597                ));
598            }
599        };
600
601        if !(100..=599).contains(&problem.status) {
602            return Err(ProblemConversionError::InvalidStatus(problem.status));
603        }
604
605        if !canonical.is_same_status_class(problem.status) {
606            return Err(ProblemConversionError::CategoryStatusMismatch {
607                category: canonical.category_name(),
608                status: problem.status,
609            });
610        }
611
612        // Recover any transport override purely from the wire `status` — no
613        // additional field on `Problem` is needed since the category's
614        // default status is already known (`default_http_status`).
615        if problem.status != canonical.default_http_status() {
616            canonical.transport_overrides_mut().http_status = Some(problem.status);
617        }
618
619        Ok(canonical)
620    }
621}
622
623// ---------------------------------------------------------------------------
624// axum integration (feature = "axum")
625// ---------------------------------------------------------------------------
626
627#[cfg(feature = "axum")]
628fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
629    if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
630        return None;
631    }
632
633    ServiceUnavailable::deserialize(&problem.context)
634        .ok()?
635        .retry_after_seconds
636}
637
638#[cfg(feature = "axum")]
639impl axum::response::IntoResponse for Problem {
640    fn into_response(self) -> axum::response::Response {
641        match serde_json::to_vec(&self) {
642            Ok(body) => {
643                let status = http::StatusCode::from_u16(self.status)
644                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
645                let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
646                let mut response = (
647                    status,
648                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
649                    body,
650                )
651                    .into_response();
652
653                if let Some(seconds) = retry_after_seconds {
654                    response
655                        .headers_mut()
656                        .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
657                }
658
659                response
660            }
661            Err(e) => {
662                tracing::error!(
663                    error = %e,
664                    problem_type = %self.problem_type,
665                    status = self.status,
666                    "failed to serialize Problem; emitting fallback body",
667                );
668                let body = format!(
669                    r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
670                    PROBLEM_TYPE_PREFIX,
671                    gts_type_prefix(),
672                    GTS_TYPE_SUFFIX
673                );
674                (
675                    http::StatusCode::INTERNAL_SERVER_ERROR,
676                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
677                    body,
678                )
679                    .into_response()
680            }
681        }
682    }
683}
684
685#[cfg(feature = "axum")]
686impl axum::response::IntoResponse for CanonicalError {
687    fn into_response(self) -> axum::response::Response {
688        // Stash a clone of self into the response extensions so the canonical
689        // error middleware (DESIGN.md §3.6) can recover `diagnostic()` and log
690        // the unredacted description server-side without putting it on the
691        // wire. The `description` fields on `Internal` / `Unknown` are
692        // `#[serde(skip)]`, so the bytes-roundtrip path alone cannot surface
693        // them.
694        let for_extension = self.clone();
695        let mut response = Problem::from(self).into_response();
696        response.extensions_mut().insert(for_extension);
697        response
698    }
699}
700
701// ---------------------------------------------------------------------------
702// utoipa integration (feature = "utoipa")
703// ---------------------------------------------------------------------------
704
705#[cfg(feature = "utoipa")]
706impl utoipa::PartialSchema for Problem {
707    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
708        use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
709
710        ObjectBuilder::new()
711            .property(
712                "type",
713                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
714            )
715            .required("type")
716            .property(
717                "title",
718                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
719            )
720            .required("title")
721            .property(
722                "status",
723                ObjectBuilder::new()
724                    .schema_type(SchemaType::Type(Type::Integer))
725                    .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
726            )
727            .required("status")
728            .property(
729                "detail",
730                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
731            )
732            .required("detail")
733            .property(
734                "instance",
735                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
736            )
737            .property(
738                "trace_id",
739                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
740            )
741            .property(
742                "context",
743                ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
744            )
745            .required("context")
746            .description(Some(
747                "RFC 9457 problem+json. `context` varies by error category.",
748            ))
749            .into()
750    }
751}
752
753#[cfg(feature = "utoipa")]
754impl utoipa::ToSchema for Problem {
755    fn name() -> std::borrow::Cow<'static, str> {
756        std::borrow::Cow::Borrowed("Problem")
757    }
758}
759
760#[cfg(test)]
761#[cfg_attr(coverage_nightly, coverage(off))]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn not_found_round_trips_through_problem() {
767        let original = CanonicalError::__not_found(crate::context::NotFound::new())
768            .with_detail("invoice 42 missing")
769            .with_resource_type("invoice")
770            .with_resource("42");
771
772        let problem = Problem::from(original.clone());
773        let recovered = CanonicalError::try_from(problem).expect("known problem_type");
774
775        assert!(matches!(recovered, CanonicalError::NotFound { .. }));
776        assert_eq!(recovered.detail(), original.detail());
777        assert_eq!(recovered.resource_type(), Some("invoice"));
778        assert_eq!(recovered.resource_name(), Some("42"));
779    }
780
781    #[test]
782    fn already_exists_round_trips_through_problem() {
783        let original = CanonicalError::__already_exists(crate::context::AlreadyExists::new())
784            .with_detail("duplicate payment")
785            .with_resource_type("payment")
786            .with_resource("xyz");
787
788        let problem = Problem::from(original);
789        let recovered = CanonicalError::try_from(problem).expect("known problem_type");
790
791        assert!(matches!(recovered, CanonicalError::AlreadyExists { .. }));
792        assert_eq!(recovered.resource_type(), Some("payment"));
793        assert_eq!(recovered.resource_name(), Some("xyz"));
794    }
795
796    #[test]
797    fn permission_denied_round_trips_through_problem() {
798        let original = CanonicalError::__permission_denied(crate::context::PermissionDenied::new(
799            "missing scope",
800        ))
801        .with_detail("forbidden")
802        .with_resource_type("invoice")
803        .with_resource("42");
804
805        let problem = Problem::from(original);
806        let recovered = CanonicalError::try_from(problem).expect("known problem_type");
807
808        assert!(matches!(recovered, CanonicalError::PermissionDenied { .. }));
809        assert_eq!(recovered.resource_type(), Some("invoice"));
810        assert_eq!(recovered.resource_name(), Some("42"));
811    }
812
813    #[test]
814    fn unknown_problem_type_errors() {
815        let problem = Problem {
816            problem_type: "gts://something.else.unknown".to_owned(),
817            title: "X".to_owned(),
818            status: 500,
819            detail: String::new(),
820            instance: None,
821            trace_id: None,
822            context: serde_json::json!({}),
823            error_code: None,
824            error_domain: None,
825        };
826        assert!(CanonicalError::try_from(problem).is_err());
827    }
828}