Skip to main content

modkit_canonical_errors/
problem.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::CanonicalError;
4
5/// Media type for RFC 9457 `application/problem+json` responses.
6pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
7
8// ---------------------------------------------------------------------------
9// Problem (RFC 9457)
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Problem {
14    #[serde(rename = "type")]
15    pub problem_type: String,
16    pub title: String,
17    pub status: u16,
18    pub detail: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub instance: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub trace_id: Option<String>,
23    pub context: serde_json::Value,
24}
25
26impl Problem {
27    /// Convert a `CanonicalError` to a `Problem`.
28    ///
29    /// # Errors
30    ///
31    /// Returns `serde_json::Error` if the error-category context type
32    /// fails to serialize.  Built-in context types are plain structs and
33    /// should never fail, but this keeps the failure visible rather than
34    /// silently producing an empty `"context": {}`.
35    pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
36        let problem_type = format!("gts://{}", err.gts_type());
37        let title = err.title().to_owned();
38        let status = err.status_code();
39        let detail = err.detail().to_owned();
40
41        let mut context = serialize_context(err)?;
42
43        if let Some(rt) = err.resource_type() {
44            context["resource_type"] = serde_json::Value::String(rt.to_owned());
45        }
46
47        if let Some(rn) = err.resource_name() {
48            context["resource_name"] = serde_json::Value::String(rn.to_owned());
49        }
50
51        Ok(Problem {
52            problem_type,
53            title,
54            status,
55            detail,
56            instance: None,
57            trace_id: None,
58            context,
59        })
60    }
61
62    /// Convert a `CanonicalError` to a `Problem`, including the internal
63    /// diagnostic string in the `context` for `Internal` and `Unknown`
64    /// variants.
65    ///
66    /// **This method MUST NOT be used in production.** It exists so that
67    /// development and test environments can surface the real error cause
68    /// in the wire response for easier debugging.
69    ///
70    /// In production, use [`from_error`](Self::from_error) instead — it
71    /// never leaks the diagnostic string.
72    ///
73    /// # Errors
74    ///
75    /// Returns `serde_json::Error` if the context fails to serialize.
76    pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
77        let mut problem = Self::from_error(err)?;
78
79        if let Some(diag) = err.diagnostic() {
80            problem.context["description"] = serde_json::Value::String(diag.to_owned());
81        }
82
83        Ok(problem)
84    }
85
86    /// Set the `trace_id` field, returning `self` for chaining.
87    #[must_use]
88    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
89        self.trace_id = Some(trace_id.into());
90        self
91    }
92
93    /// Set the `instance` field, returning `self` for chaining.
94    #[must_use]
95    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
96        self.instance = Some(instance.into());
97        self
98    }
99}
100
101fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
102    match err {
103        CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
104        CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
105        CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
106        CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
107        CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
108        CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
109        CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
110        CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
111        CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
112        CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
113        CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
114        CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
115        CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
116        CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
117        CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
118        CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
119    }
120}
121
122impl From<CanonicalError> for Problem {
123    fn from(err: CanonicalError) -> Self {
124        match Problem::from_error(&err) {
125            Ok(p) => p,
126            Err(ser_err) => Problem {
127                problem_type: format!("gts://{}", err.gts_type()),
128                title: err.title().to_owned(),
129                status: err.status_code(),
130                detail: err.detail().to_owned(),
131                instance: None,
132                trace_id: None,
133                context: serde_json::Value::String(ser_err.to_string()),
134            },
135        }
136    }
137}
138
139// ---------------------------------------------------------------------------
140// axum integration (feature = "axum")
141// ---------------------------------------------------------------------------
142
143#[cfg(feature = "axum")]
144impl axum::response::IntoResponse for Problem {
145    fn into_response(self) -> axum::response::Response {
146        match serde_json::to_vec(&self) {
147            Ok(body) => {
148                let status = http::StatusCode::from_u16(self.status)
149                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
150                (
151                    status,
152                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
153                    body,
154                )
155                    .into_response()
156            }
157            Err(e) => {
158                tracing::error!(
159                    error = %e,
160                    problem_type = %self.problem_type,
161                    status = self.status,
162                    "failed to serialize Problem; emitting fallback body",
163                );
164                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":{}}"#;
165                (
166                    http::StatusCode::INTERNAL_SERVER_ERROR,
167                    [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
168                    body,
169                )
170                    .into_response()
171            }
172        }
173    }
174}
175
176#[cfg(feature = "axum")]
177impl axum::response::IntoResponse for CanonicalError {
178    fn into_response(self) -> axum::response::Response {
179        Problem::from(self).into_response()
180    }
181}
182
183// ---------------------------------------------------------------------------
184// utoipa integration (feature = "utoipa")
185// ---------------------------------------------------------------------------
186
187#[cfg(feature = "utoipa")]
188impl utoipa::PartialSchema for Problem {
189    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
190        use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
191
192        ObjectBuilder::new()
193            .property(
194                "type",
195                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
196            )
197            .required("type")
198            .property(
199                "title",
200                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
201            )
202            .required("title")
203            .property(
204                "status",
205                ObjectBuilder::new()
206                    .schema_type(SchemaType::Type(Type::Integer))
207                    .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
208            )
209            .required("status")
210            .property(
211                "detail",
212                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
213            )
214            .required("detail")
215            .property(
216                "instance",
217                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
218            )
219            .property(
220                "trace_id",
221                ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
222            )
223            .property(
224                "context",
225                ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
226            )
227            .required("context")
228            .description(Some(
229                "RFC 9457 problem+json. `context` varies by error category.",
230            ))
231            .into()
232    }
233}
234
235#[cfg(feature = "utoipa")]
236impl utoipa::ToSchema for Problem {
237    fn name() -> std::borrow::Cow<'static, str> {
238        std::borrow::Cow::Borrowed("Problem")
239    }
240}