1use serde::{Deserialize, Serialize};
2
3use 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
16pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
18
19#[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 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 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 #[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 #[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#[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
154const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
174
175#[derive(Debug, thiserror::Error)]
177pub enum ProblemConversionError {
178 #[error("unrecognized problem_type: {0}")]
182 UnknownProblemType(String),
183
184 #[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#[allow(unknown_lints, de0901_gts_string_pattern)]
200fn 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}
214const GTS_TYPE_SUFFIX: &str = ".v1~";
216
217fn 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
225fn 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 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#[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 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#[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}