1use serde::{Deserialize, Serialize};
2
3use toolkit_gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX, gts_id, 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
17pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
19
20#[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 #[must_use]
59 pub fn gts_fragment(self) -> &'static str {
60 match self {
61 Self::Cancelled => gts_id!("cf.core.errors.err.v1~cf.core.err.cancelled.v1~"),
62 Self::Unknown => gts_id!("cf.core.errors.err.v1~cf.core.err.unknown.v1~"),
63 Self::InvalidArgument => {
64 gts_id!("cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~")
65 }
66 Self::DeadlineExceeded => {
67 gts_id!("cf.core.errors.err.v1~cf.core.err.deadline_exceeded.v1~")
68 }
69 Self::NotFound => gts_id!("cf.core.errors.err.v1~cf.core.err.not_found.v1~"),
70 Self::AlreadyExists => {
71 gts_id!("cf.core.errors.err.v1~cf.core.err.already_exists.v1~")
72 }
73 Self::PermissionDenied => {
74 gts_id!("cf.core.errors.err.v1~cf.core.err.permission_denied.v1~")
75 }
76 Self::ResourceExhausted => {
77 gts_id!("cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~")
78 }
79 Self::FailedPrecondition => {
80 gts_id!("cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~")
81 }
82 Self::Aborted => gts_id!("cf.core.errors.err.v1~cf.core.err.aborted.v1~"),
83 Self::OutOfRange => gts_id!("cf.core.errors.err.v1~cf.core.err.out_of_range.v1~"),
84 Self::Unimplemented => {
85 gts_id!("cf.core.errors.err.v1~cf.core.err.unimplemented.v1~")
86 }
87 Self::Internal => gts_id!("cf.core.errors.err.v1~cf.core.err.internal.v1~"),
88 Self::ServiceUnavailable => {
89 gts_id!("cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~")
90 }
91 Self::DataLoss => gts_id!("cf.core.errors.err.v1~cf.core.err.data_loss.v1~"),
92 Self::Unauthenticated => {
93 gts_id!("cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~")
94 }
95 }
96 }
97
98 #[must_use]
100 #[allow(
101 clippy::match_same_arms,
102 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."
103 )]
104 pub fn http_status(self) -> u16 {
105 match self {
106 Self::Cancelled => 499,
107 Self::Unknown => 500,
108 Self::InvalidArgument => 400,
109 Self::DeadlineExceeded => 504,
110 Self::NotFound => 404,
111 Self::AlreadyExists => 409,
112 Self::PermissionDenied => 403,
113 Self::ResourceExhausted => 429,
114 Self::FailedPrecondition => 400,
115 Self::Aborted => 409,
116 Self::OutOfRange => 400,
117 Self::Unimplemented => 501,
118 Self::Internal => 500,
119 Self::ServiceUnavailable => 503,
120 Self::DataLoss => 500,
121 Self::Unauthenticated => 401,
122 }
123 }
124
125 #[must_use]
128 pub fn title(self) -> &'static str {
129 match self {
130 Self::Cancelled => "Cancelled",
131 Self::Unknown => "Unknown",
132 Self::InvalidArgument => "Invalid argument",
133 Self::DeadlineExceeded => "Deadline exceeded",
134 Self::NotFound => "Not found",
135 Self::AlreadyExists => "Already exists",
136 Self::PermissionDenied => "Permission denied",
137 Self::ResourceExhausted => "Resource exhausted",
138 Self::FailedPrecondition => "Failed precondition",
139 Self::Aborted => "Aborted",
140 Self::OutOfRange => "Out of range",
141 Self::Unimplemented => "Unimplemented",
142 Self::Internal => "Internal",
143 Self::ServiceUnavailable => "Service unavailable",
144 Self::DataLoss => "Data loss",
145 Self::Unauthenticated => "Unauthenticated",
146 }
147 }
148}
149
150fn default_problem_type() -> String {
157 "about:blank".to_owned()
158}
159
160fn default_context() -> serde_json::Value {
167 serde_json::Value::Object(serde_json::Map::new())
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Problem {
180 #[serde(rename = "type", default = "default_problem_type")]
181 pub problem_type: String,
182 #[serde(default)]
183 pub title: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub status: Option<u16>,
186 #[serde(default)]
187 pub detail: String,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub instance: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub trace_id: Option<String>,
192 #[serde(default = "default_context")]
193 pub context: serde_json::Value,
194
195 #[serde(skip_serializing_if = "Option::is_none")]
201 pub error_code: Option<String>,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
207 pub error_domain: Option<String>,
208}
209
210impl Problem {
211 pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
220 let problem_type = gts_uri!(err.gts_type());
221 let title = err.title().to_owned();
222 let status = err.status_code();
223 let detail = err.detail().to_owned();
224
225 let mut context = serialize_context(err)?;
226
227 if let Some(rt) = err.resource_type() {
228 context["resource_type"] = serde_json::Value::String(rt.to_owned());
229 }
230
231 if let Some(rn) = err.resource_name() {
232 context["resource_name"] = serde_json::Value::String(rn.to_owned());
233 }
234
235 Ok(Problem {
236 problem_type,
237 title,
238 status: Some(status),
239 detail,
240 instance: None,
241 trace_id: None,
242 context,
243 error_code: None,
244 error_domain: None,
245 })
246 }
247
248 #[must_use]
251 pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
252 self.error_code = Some(code.into());
253 self
254 }
255
256 #[must_use]
258 pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
259 self.error_domain = Some(domain.into());
260 self
261 }
262
263 pub fn contract_error(
274 category: ProblemCategory,
275 error_code: impl Into<String>,
276 error_domain: impl Into<String>,
277 detail: impl Into<String>,
278 data: serde_json::Value,
279 ) -> Self {
280 let mut context = serde_json::Map::new();
281 context.insert("data".to_owned(), data);
282 Problem {
283 problem_type: format!("gts://{}", category.gts_fragment()),
284 title: category.title().to_owned(),
285 status: Some(category.http_status()),
286 detail: detail.into(),
287 instance: None,
288 trace_id: None,
289 context: serde_json::Value::Object(context),
290 error_code: Some(error_code.into()),
291 error_domain: Some(error_domain.into()),
292 }
293 }
294
295 #[cfg(feature = "debug-problem")]
314 pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
315 let mut problem = Self::from_error(err)?;
316
317 if let Some(diag) = err.diagnostic() {
318 problem.context["description"] = serde_json::Value::String(diag.to_owned());
319 }
320
321 Ok(problem)
322 }
323
324 #[must_use]
326 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
327 self.trace_id = Some(trace_id.into());
328 self
329 }
330
331 #[must_use]
333 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
334 self.instance = Some(instance.into());
335 self
336 }
337}
338
339fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
340 match err {
341 CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
342 CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
343 CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
344 CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
345 CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
346 CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
347 CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
348 CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
349 CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
350 CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
351 CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
352 CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
353 CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
354 CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
355 CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
356 CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
357 }
358}
359
360#[allow(unknown_lints, de1302_error_from_to_string)]
364impl From<CanonicalError> for Problem {
365 fn from(err: CanonicalError) -> Self {
366 match Problem::from_error(&err) {
367 Ok(p) => p,
368 Err(ser_err) => Problem {
369 problem_type: gts_uri!(err.gts_type()),
370 title: err.title().to_owned(),
371 status: Some(err.status_code()),
372 detail: err.detail().to_owned(),
373 instance: None,
374 trace_id: None,
375 context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
376 error_code: None,
377 error_domain: None,
378 },
379 }
380 }
381}
382
383const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
403
404#[derive(Debug, thiserror::Error)]
406pub enum ProblemConversionError {
407 #[error("unrecognized problem_type: {0}")]
411 UnknownProblemType(String),
412
413 #[error("invalid context for canonical category {category}: {source}")]
417 InvalidContext {
418 category: &'static str,
419 #[source]
420 source: serde_json::Error,
421 },
422
423 #[error("invalid HTTP status in Problem: {0}")]
428 InvalidStatus(u16),
429
430 #[error("status {status} is a different HTTP status class than category {category}'s default")]
436 CategoryStatusMismatch { category: &'static str, status: u16 },
437}
438
439#[allow(unknown_lints, de0901_gts_string_pattern)]
444fn gts_type_prefix() -> &'static str {
454 use std::sync::OnceLock;
455 static PREFIX: OnceLock<String> = OnceLock::new();
456 PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
457}
458const GTS_TYPE_SUFFIX: &str = ".v1~";
460
461fn category_from_problem_type(problem_type: &str) -> Option<&str> {
464 let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
465 let after_prefix = rest.strip_prefix(gts_type_prefix())?;
466 after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
467}
468
469fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
475 let resource_type = context
476 .get("resource_type")
477 .and_then(serde_json::Value::as_str)
478 .map(str::to_owned);
479 let resource_name = context
480 .get("resource_name")
481 .and_then(serde_json::Value::as_str)
482 .map(str::to_owned);
483 (resource_type, resource_name)
484}
485
486fn deserialize_ctx<T>(
487 category: &'static str,
488 context: serde_json::Value,
489) -> Result<T, ProblemConversionError>
490where
491 T: serde::de::DeserializeOwned,
492{
493 serde_json::from_value(context)
494 .map_err(|source| ProblemConversionError::InvalidContext { category, source })
495}
496
497impl TryFrom<Problem> for CanonicalError {
498 type Error = ProblemConversionError;
499
500 fn try_from(problem: Problem) -> Result<Self, Self::Error> {
501 let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
502 ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
503 })?;
504
505 let detail = problem.detail;
506 let (resource_type, resource_name) = extract_resource_fields(&problem.context);
507 let ctx_value = problem.context;
508
509 let mut canonical = match category {
510 "cancelled" => Self::Cancelled {
511 ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
512 detail,
513 resource_type,
514 resource_name,
515 overrides: TransportOverrides::default(),
516 },
517 "unknown" => Self::Unknown {
518 ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
519 detail,
520 resource_type,
521 resource_name,
522 overrides: TransportOverrides::default(),
523 },
524 "invalid_argument" => Self::InvalidArgument {
525 ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
526 detail,
527 resource_type,
528 resource_name,
529 overrides: TransportOverrides::default(),
530 },
531 "deadline_exceeded" => Self::DeadlineExceeded {
532 ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
533 detail,
534 resource_type,
535 resource_name,
536 overrides: TransportOverrides::default(),
537 },
538 "not_found" => Self::NotFound {
539 ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
540 detail,
541 resource_type,
542 resource_name,
543 overrides: TransportOverrides::default(),
544 },
545 "already_exists" => Self::AlreadyExists {
546 ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
547 detail,
548 resource_type,
549 resource_name,
550 overrides: TransportOverrides::default(),
551 },
552 "permission_denied" => Self::PermissionDenied {
553 ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
554 detail,
555 resource_type,
556 resource_name,
557 overrides: TransportOverrides::default(),
558 },
559 "resource_exhausted" => Self::ResourceExhausted {
560 ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
561 detail,
562 resource_type,
563 resource_name,
564 overrides: TransportOverrides::default(),
565 },
566 "failed_precondition" => Self::FailedPrecondition {
567 ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
568 detail,
569 resource_type,
570 resource_name,
571 overrides: TransportOverrides::default(),
572 },
573 "aborted" => Self::Aborted {
574 ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
575 detail,
576 resource_type,
577 resource_name,
578 overrides: TransportOverrides::default(),
579 },
580 "out_of_range" => Self::OutOfRange {
581 ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
582 detail,
583 resource_type,
584 resource_name,
585 overrides: TransportOverrides::default(),
586 },
587 "unimplemented" => Self::Unimplemented {
588 ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
589 detail,
590 resource_type,
591 resource_name,
592 overrides: TransportOverrides::default(),
593 },
594 "internal" => Self::Internal {
595 ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
598 detail,
599 overrides: TransportOverrides::default(),
600 },
601 "service_unavailable" => Self::ServiceUnavailable {
602 ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
603 detail,
604 resource_type,
605 resource_name,
606 overrides: TransportOverrides::default(),
607 },
608 "data_loss" => Self::DataLoss {
609 ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
610 detail,
611 resource_type,
612 resource_name,
613 overrides: TransportOverrides::default(),
614 },
615 "unauthenticated" => Self::Unauthenticated {
616 ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
617 detail,
618 resource_type,
619 resource_name,
620 overrides: TransportOverrides::default(),
621 },
622 _ => {
623 return Err(ProblemConversionError::UnknownProblemType(
624 problem.problem_type,
625 ));
626 }
627 };
628
629 if let Some(status) = problem.status {
632 if !(100..=599).contains(&status) {
633 return Err(ProblemConversionError::InvalidStatus(status));
634 }
635
636 if !canonical.is_same_status_class(status) {
637 return Err(ProblemConversionError::CategoryStatusMismatch {
638 category: canonical.category_name(),
639 status,
640 });
641 }
642
643 if status != canonical.default_http_status() {
648 canonical.transport_overrides_mut().http_status = Some(status);
649 }
650 }
651
652 Ok(canonical)
653 }
654}
655
656#[cfg(feature = "axum")]
661fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
662 if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
663 return None;
664 }
665
666 ServiceUnavailable::deserialize(&problem.context)
667 .ok()?
668 .retry_after_seconds
669}
670
671#[cfg(feature = "axum")]
672impl axum::response::IntoResponse for Problem {
673 fn into_response(self) -> axum::response::Response {
674 match serde_json::to_vec(&self) {
675 Ok(body) => {
676 let status = self
677 .status
678 .and_then(|s| http::StatusCode::from_u16(s).ok())
679 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
680 let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
681 let mut response = (
682 status,
683 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
684 body,
685 )
686 .into_response();
687
688 if let Some(seconds) = retry_after_seconds {
689 response
690 .headers_mut()
691 .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
692 }
693
694 response
695 }
696 Err(e) => {
697 tracing::error!(
698 error = %e,
699 problem_type = %self.problem_type,
700 status = ?self.status,
701 "failed to serialize Problem; emitting fallback body",
702 );
703 let body = format!(
704 r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
705 PROBLEM_TYPE_PREFIX,
706 gts_type_prefix(),
707 GTS_TYPE_SUFFIX
708 );
709 (
710 http::StatusCode::INTERNAL_SERVER_ERROR,
711 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
712 body,
713 )
714 .into_response()
715 }
716 }
717 }
718}
719
720#[cfg(feature = "axum")]
721impl axum::response::IntoResponse for CanonicalError {
722 fn into_response(self) -> axum::response::Response {
723 let for_extension = self.clone();
730 let mut response = Problem::from(self).into_response();
731 response.extensions_mut().insert(for_extension);
732 response
733 }
734}
735
736#[cfg(feature = "utoipa")]
741impl utoipa::PartialSchema for Problem {
742 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
743 use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
744
745 ObjectBuilder::new()
746 .property(
747 "type",
748 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
749 )
750 .required("type")
751 .property(
752 "title",
753 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
754 )
755 .required("title")
756 .property(
757 "status",
758 ObjectBuilder::new()
759 .schema_type(SchemaType::Type(Type::Integer))
760 .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
761 )
762 .required("status")
763 .property(
764 "detail",
765 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
766 )
767 .required("detail")
768 .property(
769 "instance",
770 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
771 )
772 .property(
773 "trace_id",
774 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
775 )
776 .property(
777 "context",
778 ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
779 )
780 .required("context")
781 .description(Some(
782 "RFC 9457 problem+json. `context` varies by error category.",
783 ))
784 .into()
785 }
786}
787
788#[cfg(feature = "utoipa")]
789impl utoipa::ToSchema for Problem {
790 fn name() -> std::borrow::Cow<'static, str> {
791 std::borrow::Cow::Borrowed("Problem")
792 }
793}
794
795#[cfg(test)]
796#[cfg_attr(coverage_nightly, coverage(off))]
797#[path = "problem_tests.rs"]
798mod tests;