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;
15
16/// Media type for RFC 9457 `application/problem+json` responses.
17pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
18
19// ---------------------------------------------------------------------------
20// Problem (RFC 9457)
21// ---------------------------------------------------------------------------
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Problem {
25    #[serde(rename = "type")]
26    pub problem_type: String,
27    pub title: String,
28    pub status: u16,
29    pub detail: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub instance: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub trace_id: Option<String>,
34    pub context: serde_json::Value,
35}
36
37impl Problem {
38    /// Convert a `CanonicalError` to a `Problem`.
39    ///
40    /// # Errors
41    ///
42    /// Returns `serde_json::Error` if the error-category context type
43    /// fails to serialize.  Built-in context types are plain structs and
44    /// should never fail, but this keeps the failure visible rather than
45    /// silently producing an empty `"context": {}`.
46    pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
47        let problem_type = gts_uri!(err.gts_type());
48        let title = err.title().to_owned();
49        let status = err.status_code();
50        let detail = err.detail().to_owned();
51
52        let mut context = serialize_context(err)?;
53
54        if let Some(rt) = err.resource_type() {
55            context["resource_type"] = serde_json::Value::String(rt.to_owned());
56        }
57
58        if let Some(rn) = err.resource_name() {
59            context["resource_name"] = serde_json::Value::String(rn.to_owned());
60        }
61
62        Ok(Problem {
63            problem_type,
64            title,
65            status,
66            detail,
67            instance: None,
68            trace_id: None,
69            context,
70        })
71    }
72
73    /// Convert a `CanonicalError` to a `Problem`, including the internal
74    /// diagnostic string in the `context` for `Internal` and `Unknown`
75    /// variants.
76    ///
77    /// **This method MUST NOT be used in production.** It exists so that
78    /// development and test environments can surface the real error cause
79    /// in the wire response for easier debugging.
80    ///
81    /// In production, use [`from_error`](Self::from_error) instead — it
82    /// never leaks the diagnostic string.
83    ///
84    /// # Errors
85    ///
86    /// Returns `serde_json::Error` if the context fails to serialize.
87    pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
88        let mut problem = Self::from_error(err)?;
89
90        if let Some(diag) = err.diagnostic() {
91            problem.context["description"] = serde_json::Value::String(diag.to_owned());
92        }
93
94        Ok(problem)
95    }
96
97    /// Set the `trace_id` field, returning `self` for chaining.
98    #[must_use]
99    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
100        self.trace_id = Some(trace_id.into());
101        self
102    }
103
104    /// Set the `instance` field, returning `self` for chaining.
105    #[must_use]
106    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
107        self.instance = Some(instance.into());
108        self
109    }
110}
111
112fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
113    match err {
114        CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
115        CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
116        CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
117        CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
118        CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
119        CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
120        CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
121        CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
122        CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
123        CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
124        CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
125        CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
126        CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
127        CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
128        CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
129        CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
130    }
131}
132
133// `Problem.context` is `serde_json::Value`, so stringifying the serialization
134// error is the intended fallback here. The original CanonicalError is already
135// preserved in the other Problem fields.
136#[allow(unknown_lints, de1302_error_from_to_string)]
137impl From<CanonicalError> for Problem {
138    fn from(err: CanonicalError) -> Self {
139        match Problem::from_error(&err) {
140            Ok(p) => p,
141            Err(ser_err) => Problem {
142                problem_type: gts_uri!(err.gts_type()),
143                title: err.title().to_owned(),
144                status: err.status_code(),
145                detail: err.detail().to_owned(),
146                instance: None,
147                trace_id: None,
148                context: serde_json::Value::String(ser_err.to_string()),
149            },
150        }
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Round-trip: Problem → CanonicalError
156//
157// Reverse direction of `From<CanonicalError> for Problem`. Out-of-process SDK
158// consumers receive `application/problem+json` over the wire, deserialize into
159// `Problem`, and reconstruct the typed `CanonicalError` via this `TryFrom`.
160// In-process consumers do not need this hop — they hold `CanonicalError`
161// directly from the ClientHub call.
162//
163// Lossy by design:
164// * `Internal.description` and `Unknown.description` are `#[serde(skip)]` on
165//   the wire, so they reconstruct as empty strings. This is intentional —
166//   production wire responses never carry the server-side diagnostic.
167// * Transport fields (`instance`, `trace_id`) live on `Problem`, not on
168//   `CanonicalError`. Callers that need them should read them off the
169//   `Problem` before converting.
170// ---------------------------------------------------------------------------
171
172/// Prefix on `Problem.problem_type` produced by the forward conversion.
173const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
174
175/// Reasons a `Problem` cannot be reconstructed as a `CanonicalError`.
176#[derive(Debug, thiserror::Error)]
177pub enum ProblemConversionError {
178    /// The `problem_type` URI does not match any of the 16 canonical
179    /// category identifiers. Either the server emitted a non-canonical
180    /// problem or the wire format has drifted.
181    #[error("unrecognized problem_type: {0}")]
182    UnknownProblemType(String),
183
184    /// The `context` payload could not be deserialized into the context
185    /// type for the matched category. The category and underlying serde
186    /// error are surfaced for diagnostics.
187    #[error("invalid context for canonical category {category}: {source}")]
188    InvalidContext {
189        category: &'static str,
190        #[source]
191        source: serde_json::Error,
192    },
193}
194
195/// Prefix of every canonical GTS identifier. Stripped to expose the category
196/// name (e.g. `cancelled`, `invalid_argument`). Not a complete GTS string by
197/// itself — only the concatenation `{prefix}{category}{suffix}` is a valid
198/// GTS identifier.
199#[allow(unknown_lints, de0901_gts_string_pattern)]
200/// Prefix of every canonical GTS error type id. Built by concatenating the
201/// configured GTS ID prefix (overridable via `GTS_ID_PREFIX`
202/// at compile time) with the fixed "cf.core.errors.err.v1~cf.core.err."
203/// suffix. This is *not* a complete GTS id (the trailing per-error token is
204/// appended at runtime in `CanonicalError::gts_type`), so `gts_id!` (which
205/// validates a full id at macro-expansion time) is not applicable here.
206/// `concat!` also cannot be used since it only accepts literals; the value
207/// is therefore materialised once via a `OnceLock` and exposed as a
208/// `&'static str`.
209fn gts_type_prefix() -> &'static str {
210    use std::sync::OnceLock;
211    static PREFIX: OnceLock<String> = OnceLock::new();
212    PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
213}
214/// Suffix of every canonical GTS identifier. See [`gts_type_prefix`].
215const GTS_TYPE_SUFFIX: &str = ".v1~";
216
217/// Strip the canonical problem-type URI down to
218/// `<category>`. Returns `None` if the URI doesn't match the canonical shape.
219fn category_from_problem_type(problem_type: &str) -> Option<&str> {
220    let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
221    let after_prefix = rest.strip_prefix(gts_type_prefix())?;
222    after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
223}
224
225/// Extract `resource_type` and `resource_name` from the `Problem.context`
226/// JSON, returning the pair (both `None` if absent). The forward conversion
227/// stamps these as plain string fields alongside the category-specific
228/// payload; here we read them back without disturbing the serde
229/// deserialization of the category context (serde ignores unknown fields).
230fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
231    let resource_type = context
232        .get("resource_type")
233        .and_then(serde_json::Value::as_str)
234        .map(str::to_owned);
235    let resource_name = context
236        .get("resource_name")
237        .and_then(serde_json::Value::as_str)
238        .map(str::to_owned);
239    (resource_type, resource_name)
240}
241
242fn deserialize_ctx<T>(
243    category: &'static str,
244    context: serde_json::Value,
245) -> Result<T, ProblemConversionError>
246where
247    T: serde::de::DeserializeOwned,
248{
249    serde_json::from_value(context)
250        .map_err(|source| ProblemConversionError::InvalidContext { category, source })
251}
252
253impl TryFrom<Problem> for CanonicalError {
254    type Error = ProblemConversionError;
255
256    fn try_from(problem: Problem) -> Result<Self, Self::Error> {
257        let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
258            ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
259        })?;
260
261        let detail = problem.detail;
262        let (resource_type, resource_name) = extract_resource_fields(&problem.context);
263        let ctx_value = problem.context;
264
265        let canonical = match category {
266            "cancelled" => Self::Cancelled {
267                ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
268                detail,
269                resource_type,
270                resource_name,
271            },
272            "unknown" => Self::Unknown {
273                ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
274                detail,
275                resource_type,
276                resource_name,
277            },
278            "invalid_argument" => Self::InvalidArgument {
279                ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
280                detail,
281                resource_type,
282                resource_name,
283            },
284            "deadline_exceeded" => Self::DeadlineExceeded {
285                ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
286                detail,
287                resource_type,
288                resource_name,
289            },
290            "not_found" => Self::NotFound {
291                ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
292                detail,
293                resource_type,
294                resource_name,
295            },
296            "already_exists" => Self::AlreadyExists {
297                ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
298                detail,
299                resource_type,
300                resource_name,
301            },
302            "permission_denied" => Self::PermissionDenied {
303                ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
304                detail,
305                resource_type,
306                resource_name,
307            },
308            "resource_exhausted" => Self::ResourceExhausted {
309                ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
310                detail,
311                resource_type,
312                resource_name,
313            },
314            "failed_precondition" => Self::FailedPrecondition {
315                ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
316                detail,
317                resource_type,
318                resource_name,
319            },
320            "aborted" => Self::Aborted {
321                ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
322                detail,
323                resource_type,
324                resource_name,
325            },
326            "out_of_range" => Self::OutOfRange {
327                ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
328                detail,
329                resource_type,
330                resource_name,
331            },
332            "unimplemented" => Self::Unimplemented {
333                ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
334                detail,
335                resource_type,
336                resource_name,
337            },
338            "internal" => Self::Internal {
339                // `Internal.description` is `#[serde(skip)]`; the wire
340                // never carries it, so it reconstructs as an empty string.
341                ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
342                detail,
343            },
344            "service_unavailable" => Self::ServiceUnavailable {
345                ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
346                detail,
347                resource_type,
348                resource_name,
349            },
350            "data_loss" => Self::DataLoss {
351                ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
352                detail,
353                resource_type,
354                resource_name,
355            },
356            "unauthenticated" => Self::Unauthenticated {
357                ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
358                detail,
359                resource_type,
360                resource_name,
361            },
362            _ => {
363                return Err(ProblemConversionError::UnknownProblemType(
364                    problem.problem_type,
365                ));
366            }
367        };
368
369        Ok(canonical)
370    }
371}
372
373// ---------------------------------------------------------------------------
374// axum integration (feature = "axum")
375// ---------------------------------------------------------------------------
376
377#[cfg(feature = "axum")]
378impl axum::response::IntoResponse for Problem {
379    fn into_response(self) -> axum::response::Response {
380        match serde_json::to_vec(&self) {
381            Ok(body) => {
382                let status = http::StatusCode::from_u16(self.status)
383                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
384                (
385                    status,
386                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
387                    body,
388                )
389                    .into_response()
390            }
391            Err(e) => {
392                tracing::error!(
393                    error = %e,
394                    problem_type = %self.problem_type,
395                    status = self.status,
396                    "failed to serialize Problem; emitting fallback body",
397                );
398                let body = format!(
399                    r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
400                    PROBLEM_TYPE_PREFIX,
401                    gts_type_prefix(),
402                    GTS_TYPE_SUFFIX
403                );
404                (
405                    http::StatusCode::INTERNAL_SERVER_ERROR,
406                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
407                    body,
408                )
409                    .into_response()
410            }
411        }
412    }
413}
414
415#[cfg(feature = "axum")]
416impl axum::response::IntoResponse for CanonicalError {
417    fn into_response(self) -> axum::response::Response {
418        // Stash a clone of self into the response extensions so the canonical
419        // error middleware (DESIGN.md §3.6) can recover `diagnostic()` and log
420        // the unredacted description server-side without putting it on the
421        // wire. The `description` fields on `Internal` / `Unknown` are
422        // `#[serde(skip)]`, so the bytes-roundtrip path alone cannot surface
423        // them.
424        let for_extension = self.clone();
425        let mut response = Problem::from(self).into_response();
426        response.extensions_mut().insert(for_extension);
427        response
428    }
429}
430
431// ---------------------------------------------------------------------------
432// utoipa integration (feature = "utoipa")
433// ---------------------------------------------------------------------------
434
435#[cfg(feature = "utoipa")]
436impl utoipa::PartialSchema for Problem {
437    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
438        use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
439
440        ObjectBuilder::new()
441            .property(
442                "type",
443                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
444            )
445            .required("type")
446            .property(
447                "title",
448                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
449            )
450            .required("title")
451            .property(
452                "status",
453                ObjectBuilder::new()
454                    .schema_type(SchemaType::Type(Type::Integer))
455                    .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
456            )
457            .required("status")
458            .property(
459                "detail",
460                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
461            )
462            .required("detail")
463            .property(
464                "instance",
465                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
466            )
467            .property(
468                "trace_id",
469                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
470            )
471            .property(
472                "context",
473                ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
474            )
475            .required("context")
476            .description(Some(
477                "RFC 9457 problem+json. `context` varies by error category.",
478            ))
479            .into()
480    }
481}
482
483#[cfg(feature = "utoipa")]
484impl utoipa::ToSchema for Problem {
485    fn name() -> std::borrow::Cow<'static, str> {
486        std::borrow::Cow::Borrowed("Problem")
487    }
488}