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