Skip to main content

toolkit_canonical_errors/
problem.rs

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