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;
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.cf.core.errors.err.v1~cf.core.err.cancelled.v1~",
62 Self::Unknown => "gts.cf.core.errors.err.v1~cf.core.err.unknown.v1~",
63 Self::InvalidArgument => "gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~",
64 Self::DeadlineExceeded => "gts.cf.core.errors.err.v1~cf.core.err.deadline_exceeded.v1~",
65 Self::NotFound => "gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~",
66 Self::AlreadyExists => "gts.cf.core.errors.err.v1~cf.core.err.already_exists.v1~",
67 Self::PermissionDenied => "gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~",
68 Self::ResourceExhausted => {
69 "gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~"
70 }
71 Self::FailedPrecondition => {
72 "gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~"
73 }
74 Self::Aborted => "gts.cf.core.errors.err.v1~cf.core.err.aborted.v1~",
75 Self::OutOfRange => "gts.cf.core.errors.err.v1~cf.core.err.out_of_range.v1~",
76 Self::Unimplemented => "gts.cf.core.errors.err.v1~cf.core.err.unimplemented.v1~",
77 Self::Internal => "gts.cf.core.errors.err.v1~cf.core.err.internal.v1~",
78 Self::ServiceUnavailable => {
79 "gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~"
80 }
81 Self::DataLoss => "gts.cf.core.errors.err.v1~cf.core.err.data_loss.v1~",
82 Self::Unauthenticated => "gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~",
83 }
84 }
85
86 #[must_use]
88 #[allow(
89 clippy::match_same_arms,
90 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."
91 )]
92 pub fn http_status(self) -> u16 {
93 match self {
94 Self::Cancelled => 499,
95 Self::Unknown => 500,
96 Self::InvalidArgument => 400,
97 Self::DeadlineExceeded => 504,
98 Self::NotFound => 404,
99 Self::AlreadyExists => 409,
100 Self::PermissionDenied => 403,
101 Self::ResourceExhausted => 429,
102 Self::FailedPrecondition => 400,
103 Self::Aborted => 409,
104 Self::OutOfRange => 400,
105 Self::Unimplemented => 501,
106 Self::Internal => 500,
107 Self::ServiceUnavailable => 503,
108 Self::DataLoss => 500,
109 Self::Unauthenticated => 401,
110 }
111 }
112
113 #[must_use]
116 pub fn title(self) -> &'static str {
117 match self {
118 Self::Cancelled => "Cancelled",
119 Self::Unknown => "Unknown",
120 Self::InvalidArgument => "Invalid argument",
121 Self::DeadlineExceeded => "Deadline exceeded",
122 Self::NotFound => "Not found",
123 Self::AlreadyExists => "Already exists",
124 Self::PermissionDenied => "Permission denied",
125 Self::ResourceExhausted => "Resource exhausted",
126 Self::FailedPrecondition => "Failed precondition",
127 Self::Aborted => "Aborted",
128 Self::OutOfRange => "Out of range",
129 Self::Unimplemented => "Unimplemented",
130 Self::Internal => "Internal",
131 Self::ServiceUnavailable => "Service unavailable",
132 Self::DataLoss => "Data loss",
133 Self::Unauthenticated => "Unauthenticated",
134 }
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Problem {
144 #[serde(rename = "type")]
145 pub problem_type: String,
146 pub title: String,
147 pub status: u16,
148 pub detail: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub instance: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub trace_id: Option<String>,
153 pub context: serde_json::Value,
154
155 #[serde(skip_serializing_if = "Option::is_none")]
161 pub error_code: Option<String>,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
167 pub error_domain: Option<String>,
168}
169
170impl Problem {
171 pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
180 let problem_type = gts_uri!(err.gts_type());
181 let title = err.title().to_owned();
182 let status = err.status_code();
183 let detail = err.detail().to_owned();
184
185 let mut context = serialize_context(err)?;
186
187 if let Some(rt) = err.resource_type() {
188 context["resource_type"] = serde_json::Value::String(rt.to_owned());
189 }
190
191 if let Some(rn) = err.resource_name() {
192 context["resource_name"] = serde_json::Value::String(rn.to_owned());
193 }
194
195 Ok(Problem {
196 problem_type,
197 title,
198 status,
199 detail,
200 instance: None,
201 trace_id: None,
202 context,
203 error_code: None,
204 error_domain: None,
205 })
206 }
207
208 #[must_use]
211 pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
212 self.error_code = Some(code.into());
213 self
214 }
215
216 #[must_use]
218 pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
219 self.error_domain = Some(domain.into());
220 self
221 }
222
223 pub fn contract_error(
234 category: ProblemCategory,
235 error_code: impl Into<String>,
236 error_domain: impl Into<String>,
237 detail: impl Into<String>,
238 data: serde_json::Value,
239 ) -> Self {
240 let mut context = serde_json::Map::new();
241 context.insert("data".to_owned(), data);
242 Problem {
243 problem_type: format!("gts://{}", category.gts_fragment()),
244 title: category.title().to_owned(),
245 status: category.http_status(),
246 detail: detail.into(),
247 instance: None,
248 trace_id: None,
249 context: serde_json::Value::Object(context),
250 error_code: Some(error_code.into()),
251 error_domain: Some(error_domain.into()),
252 }
253 }
254
255 #[cfg(feature = "debug-problem")]
274 pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
275 let mut problem = Self::from_error(err)?;
276
277 if let Some(diag) = err.diagnostic() {
278 problem.context["description"] = serde_json::Value::String(diag.to_owned());
279 }
280
281 Ok(problem)
282 }
283
284 #[must_use]
286 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
287 self.trace_id = Some(trace_id.into());
288 self
289 }
290
291 #[must_use]
293 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
294 self.instance = Some(instance.into());
295 self
296 }
297}
298
299fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
300 match err {
301 CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
302 CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
303 CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
304 CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
305 CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
306 CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
307 CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
308 CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
309 CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
310 CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
311 CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
312 CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
313 CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
314 CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
315 CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
316 CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
317 }
318}
319
320#[allow(unknown_lints, de1302_error_from_to_string)]
324impl From<CanonicalError> for Problem {
325 fn from(err: CanonicalError) -> Self {
326 match Problem::from_error(&err) {
327 Ok(p) => p,
328 Err(ser_err) => Problem {
329 problem_type: gts_uri!(err.gts_type()),
330 title: err.title().to_owned(),
331 status: err.status_code(),
332 detail: err.detail().to_owned(),
333 instance: None,
334 trace_id: None,
335 context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
336 error_code: None,
337 error_domain: None,
338 },
339 }
340 }
341}
342
343const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
363
364#[derive(Debug, thiserror::Error)]
366pub enum ProblemConversionError {
367 #[error("unrecognized problem_type: {0}")]
371 UnknownProblemType(String),
372
373 #[error("invalid context for canonical category {category}: {source}")]
377 InvalidContext {
378 category: &'static str,
379 #[source]
380 source: serde_json::Error,
381 },
382
383 #[error("invalid HTTP status in Problem: {0}")]
388 InvalidStatus(u16),
389
390 #[error("status {status} is a different HTTP status class than category {category}'s default")]
396 CategoryStatusMismatch { category: &'static str, status: u16 },
397}
398
399#[allow(unknown_lints, de0901_gts_string_pattern)]
404fn gts_type_prefix() -> &'static str {
414 use std::sync::OnceLock;
415 static PREFIX: OnceLock<String> = OnceLock::new();
416 PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
417}
418const GTS_TYPE_SUFFIX: &str = ".v1~";
420
421fn category_from_problem_type(problem_type: &str) -> Option<&str> {
424 let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
425 let after_prefix = rest.strip_prefix(gts_type_prefix())?;
426 after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
427}
428
429fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
435 let resource_type = context
436 .get("resource_type")
437 .and_then(serde_json::Value::as_str)
438 .map(str::to_owned);
439 let resource_name = context
440 .get("resource_name")
441 .and_then(serde_json::Value::as_str)
442 .map(str::to_owned);
443 (resource_type, resource_name)
444}
445
446fn deserialize_ctx<T>(
447 category: &'static str,
448 context: serde_json::Value,
449) -> Result<T, ProblemConversionError>
450where
451 T: serde::de::DeserializeOwned,
452{
453 serde_json::from_value(context)
454 .map_err(|source| ProblemConversionError::InvalidContext { category, source })
455}
456
457impl TryFrom<Problem> for CanonicalError {
458 type Error = ProblemConversionError;
459
460 fn try_from(problem: Problem) -> Result<Self, Self::Error> {
461 let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
462 ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
463 })?;
464
465 let detail = problem.detail;
466 let (resource_type, resource_name) = extract_resource_fields(&problem.context);
467 let ctx_value = problem.context;
468
469 let mut canonical = match category {
470 "cancelled" => Self::Cancelled {
471 ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
472 detail,
473 resource_type,
474 resource_name,
475 overrides: TransportOverrides::default(),
476 },
477 "unknown" => Self::Unknown {
478 ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
479 detail,
480 resource_type,
481 resource_name,
482 overrides: TransportOverrides::default(),
483 },
484 "invalid_argument" => Self::InvalidArgument {
485 ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
486 detail,
487 resource_type,
488 resource_name,
489 overrides: TransportOverrides::default(),
490 },
491 "deadline_exceeded" => Self::DeadlineExceeded {
492 ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
493 detail,
494 resource_type,
495 resource_name,
496 overrides: TransportOverrides::default(),
497 },
498 "not_found" => Self::NotFound {
499 ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
500 detail,
501 resource_type,
502 resource_name,
503 overrides: TransportOverrides::default(),
504 },
505 "already_exists" => Self::AlreadyExists {
506 ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
507 detail,
508 resource_type,
509 resource_name,
510 overrides: TransportOverrides::default(),
511 },
512 "permission_denied" => Self::PermissionDenied {
513 ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
514 detail,
515 resource_type,
516 resource_name,
517 overrides: TransportOverrides::default(),
518 },
519 "resource_exhausted" => Self::ResourceExhausted {
520 ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
521 detail,
522 resource_type,
523 resource_name,
524 overrides: TransportOverrides::default(),
525 },
526 "failed_precondition" => Self::FailedPrecondition {
527 ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
528 detail,
529 resource_type,
530 resource_name,
531 overrides: TransportOverrides::default(),
532 },
533 "aborted" => Self::Aborted {
534 ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
535 detail,
536 resource_type,
537 resource_name,
538 overrides: TransportOverrides::default(),
539 },
540 "out_of_range" => Self::OutOfRange {
541 ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
542 detail,
543 resource_type,
544 resource_name,
545 overrides: TransportOverrides::default(),
546 },
547 "unimplemented" => Self::Unimplemented {
548 ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
549 detail,
550 resource_type,
551 resource_name,
552 overrides: TransportOverrides::default(),
553 },
554 "internal" => Self::Internal {
555 ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
558 detail,
559 overrides: TransportOverrides::default(),
560 },
561 "service_unavailable" => Self::ServiceUnavailable {
562 ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
563 detail,
564 resource_type,
565 resource_name,
566 overrides: TransportOverrides::default(),
567 },
568 "data_loss" => Self::DataLoss {
569 ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
570 detail,
571 resource_type,
572 resource_name,
573 overrides: TransportOverrides::default(),
574 },
575 "unauthenticated" => Self::Unauthenticated {
576 ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
577 detail,
578 resource_type,
579 resource_name,
580 overrides: TransportOverrides::default(),
581 },
582 _ => {
583 return Err(ProblemConversionError::UnknownProblemType(
584 problem.problem_type,
585 ));
586 }
587 };
588
589 if !(100..=599).contains(&problem.status) {
590 return Err(ProblemConversionError::InvalidStatus(problem.status));
591 }
592
593 if !canonical.is_same_status_class(problem.status) {
594 return Err(ProblemConversionError::CategoryStatusMismatch {
595 category: canonical.category_name(),
596 status: problem.status,
597 });
598 }
599
600 if problem.status != canonical.default_http_status() {
604 canonical.transport_overrides_mut().http_status = Some(problem.status);
605 }
606
607 Ok(canonical)
608 }
609}
610
611#[cfg(feature = "axum")]
616impl axum::response::IntoResponse for Problem {
617 fn into_response(self) -> axum::response::Response {
618 match serde_json::to_vec(&self) {
619 Ok(body) => {
620 let status = http::StatusCode::from_u16(self.status)
621 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
622 (
623 status,
624 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
625 body,
626 )
627 .into_response()
628 }
629 Err(e) => {
630 tracing::error!(
631 error = %e,
632 problem_type = %self.problem_type,
633 status = self.status,
634 "failed to serialize Problem; emitting fallback body",
635 );
636 let body = format!(
637 r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
638 PROBLEM_TYPE_PREFIX,
639 gts_type_prefix(),
640 GTS_TYPE_SUFFIX
641 );
642 (
643 http::StatusCode::INTERNAL_SERVER_ERROR,
644 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
645 body,
646 )
647 .into_response()
648 }
649 }
650 }
651}
652
653#[cfg(feature = "axum")]
654impl axum::response::IntoResponse for CanonicalError {
655 fn into_response(self) -> axum::response::Response {
656 let for_extension = self.clone();
663 let mut response = Problem::from(self).into_response();
664 response.extensions_mut().insert(for_extension);
665 response
666 }
667}
668
669#[cfg(feature = "utoipa")]
674impl utoipa::PartialSchema for Problem {
675 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
676 use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
677
678 ObjectBuilder::new()
679 .property(
680 "type",
681 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
682 )
683 .required("type")
684 .property(
685 "title",
686 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
687 )
688 .required("title")
689 .property(
690 "status",
691 ObjectBuilder::new()
692 .schema_type(SchemaType::Type(Type::Integer))
693 .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
694 )
695 .required("status")
696 .property(
697 "detail",
698 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
699 )
700 .required("detail")
701 .property(
702 "instance",
703 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
704 )
705 .property(
706 "trace_id",
707 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
708 )
709 .property(
710 "context",
711 ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
712 )
713 .required("context")
714 .description(Some(
715 "RFC 9457 problem+json. `context` varies by error category.",
716 ))
717 .into()
718 }
719}
720
721#[cfg(feature = "utoipa")]
722impl utoipa::ToSchema for Problem {
723 fn name() -> std::borrow::Cow<'static, str> {
724 std::borrow::Cow::Borrowed("Problem")
725 }
726}
727
728#[cfg(test)]
729#[cfg_attr(coverage_nightly, coverage(off))]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn not_found_round_trips_through_problem() {
735 let original = CanonicalError::__not_found(crate::context::NotFound::new())
736 .with_detail("invoice 42 missing")
737 .with_resource_type("invoice")
738 .with_resource("42");
739
740 let problem = Problem::from(original.clone());
741 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
742
743 assert!(matches!(recovered, CanonicalError::NotFound { .. }));
744 assert_eq!(recovered.detail(), original.detail());
745 assert_eq!(recovered.resource_type(), Some("invoice"));
746 assert_eq!(recovered.resource_name(), Some("42"));
747 }
748
749 #[test]
750 fn already_exists_round_trips_through_problem() {
751 let original = CanonicalError::__already_exists(crate::context::AlreadyExists::new())
752 .with_detail("duplicate payment")
753 .with_resource_type("payment")
754 .with_resource("xyz");
755
756 let problem = Problem::from(original);
757 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
758
759 assert!(matches!(recovered, CanonicalError::AlreadyExists { .. }));
760 assert_eq!(recovered.resource_type(), Some("payment"));
761 assert_eq!(recovered.resource_name(), Some("xyz"));
762 }
763
764 #[test]
765 fn permission_denied_round_trips_through_problem() {
766 let original = CanonicalError::__permission_denied(crate::context::PermissionDenied::new(
767 "missing scope",
768 ))
769 .with_detail("forbidden")
770 .with_resource_type("invoice")
771 .with_resource("42");
772
773 let problem = Problem::from(original);
774 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
775
776 assert!(matches!(recovered, CanonicalError::PermissionDenied { .. }));
777 assert_eq!(recovered.resource_type(), Some("invoice"));
778 assert_eq!(recovered.resource_name(), Some("42"));
779 }
780
781 #[test]
782 fn unknown_problem_type_errors() {
783 let problem = Problem {
784 problem_type: "gts://something.else.unknown".to_owned(),
785 title: "X".to_owned(),
786 status: 500,
787 detail: String::new(),
788 instance: None,
789 trace_id: None,
790 context: serde_json::json!({}),
791 error_code: None,
792 error_domain: None,
793 };
794 assert!(CanonicalError::try_from(problem).is_err());
795 }
796}