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
150#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct Problem {
156 #[serde(rename = "type")]
157 pub problem_type: String,
158 pub title: String,
159 pub status: u16,
160 pub detail: String,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub instance: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub trace_id: Option<String>,
165 pub context: serde_json::Value,
166
167 #[serde(skip_serializing_if = "Option::is_none")]
173 pub error_code: Option<String>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
179 pub error_domain: Option<String>,
180}
181
182impl Problem {
183 pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
192 let problem_type = gts_uri!(err.gts_type());
193 let title = err.title().to_owned();
194 let status = err.status_code();
195 let detail = err.detail().to_owned();
196
197 let mut context = serialize_context(err)?;
198
199 if let Some(rt) = err.resource_type() {
200 context["resource_type"] = serde_json::Value::String(rt.to_owned());
201 }
202
203 if let Some(rn) = err.resource_name() {
204 context["resource_name"] = serde_json::Value::String(rn.to_owned());
205 }
206
207 Ok(Problem {
208 problem_type,
209 title,
210 status,
211 detail,
212 instance: None,
213 trace_id: None,
214 context,
215 error_code: None,
216 error_domain: None,
217 })
218 }
219
220 #[must_use]
223 pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
224 self.error_code = Some(code.into());
225 self
226 }
227
228 #[must_use]
230 pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
231 self.error_domain = Some(domain.into());
232 self
233 }
234
235 pub fn contract_error(
246 category: ProblemCategory,
247 error_code: impl Into<String>,
248 error_domain: impl Into<String>,
249 detail: impl Into<String>,
250 data: serde_json::Value,
251 ) -> Self {
252 let mut context = serde_json::Map::new();
253 context.insert("data".to_owned(), data);
254 Problem {
255 problem_type: format!("gts://{}", category.gts_fragment()),
256 title: category.title().to_owned(),
257 status: category.http_status(),
258 detail: detail.into(),
259 instance: None,
260 trace_id: None,
261 context: serde_json::Value::Object(context),
262 error_code: Some(error_code.into()),
263 error_domain: Some(error_domain.into()),
264 }
265 }
266
267 #[cfg(feature = "debug-problem")]
286 pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
287 let mut problem = Self::from_error(err)?;
288
289 if let Some(diag) = err.diagnostic() {
290 problem.context["description"] = serde_json::Value::String(diag.to_owned());
291 }
292
293 Ok(problem)
294 }
295
296 #[must_use]
298 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
299 self.trace_id = Some(trace_id.into());
300 self
301 }
302
303 #[must_use]
305 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
306 self.instance = Some(instance.into());
307 self
308 }
309}
310
311fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
312 match err {
313 CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
314 CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
315 CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
316 CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
317 CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
318 CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
319 CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
320 CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
321 CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
322 CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
323 CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
324 CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
325 CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
326 CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
327 CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
328 CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
329 }
330}
331
332#[allow(unknown_lints, de1302_error_from_to_string)]
336impl From<CanonicalError> for Problem {
337 fn from(err: CanonicalError) -> Self {
338 match Problem::from_error(&err) {
339 Ok(p) => p,
340 Err(ser_err) => Problem {
341 problem_type: gts_uri!(err.gts_type()),
342 title: err.title().to_owned(),
343 status: err.status_code(),
344 detail: err.detail().to_owned(),
345 instance: None,
346 trace_id: None,
347 context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
348 error_code: None,
349 error_domain: None,
350 },
351 }
352 }
353}
354
355const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
375
376#[derive(Debug, thiserror::Error)]
378pub enum ProblemConversionError {
379 #[error("unrecognized problem_type: {0}")]
383 UnknownProblemType(String),
384
385 #[error("invalid context for canonical category {category}: {source}")]
389 InvalidContext {
390 category: &'static str,
391 #[source]
392 source: serde_json::Error,
393 },
394
395 #[error("invalid HTTP status in Problem: {0}")]
400 InvalidStatus(u16),
401
402 #[error("status {status} is a different HTTP status class than category {category}'s default")]
408 CategoryStatusMismatch { category: &'static str, status: u16 },
409}
410
411#[allow(unknown_lints, de0901_gts_string_pattern)]
416fn gts_type_prefix() -> &'static str {
426 use std::sync::OnceLock;
427 static PREFIX: OnceLock<String> = OnceLock::new();
428 PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
429}
430const GTS_TYPE_SUFFIX: &str = ".v1~";
432
433fn category_from_problem_type(problem_type: &str) -> Option<&str> {
436 let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
437 let after_prefix = rest.strip_prefix(gts_type_prefix())?;
438 after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
439}
440
441fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
447 let resource_type = context
448 .get("resource_type")
449 .and_then(serde_json::Value::as_str)
450 .map(str::to_owned);
451 let resource_name = context
452 .get("resource_name")
453 .and_then(serde_json::Value::as_str)
454 .map(str::to_owned);
455 (resource_type, resource_name)
456}
457
458fn deserialize_ctx<T>(
459 category: &'static str,
460 context: serde_json::Value,
461) -> Result<T, ProblemConversionError>
462where
463 T: serde::de::DeserializeOwned,
464{
465 serde_json::from_value(context)
466 .map_err(|source| ProblemConversionError::InvalidContext { category, source })
467}
468
469impl TryFrom<Problem> for CanonicalError {
470 type Error = ProblemConversionError;
471
472 fn try_from(problem: Problem) -> Result<Self, Self::Error> {
473 let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
474 ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
475 })?;
476
477 let detail = problem.detail;
478 let (resource_type, resource_name) = extract_resource_fields(&problem.context);
479 let ctx_value = problem.context;
480
481 let mut canonical = match category {
482 "cancelled" => Self::Cancelled {
483 ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
484 detail,
485 resource_type,
486 resource_name,
487 overrides: TransportOverrides::default(),
488 },
489 "unknown" => Self::Unknown {
490 ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
491 detail,
492 resource_type,
493 resource_name,
494 overrides: TransportOverrides::default(),
495 },
496 "invalid_argument" => Self::InvalidArgument {
497 ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
498 detail,
499 resource_type,
500 resource_name,
501 overrides: TransportOverrides::default(),
502 },
503 "deadline_exceeded" => Self::DeadlineExceeded {
504 ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
505 detail,
506 resource_type,
507 resource_name,
508 overrides: TransportOverrides::default(),
509 },
510 "not_found" => Self::NotFound {
511 ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
512 detail,
513 resource_type,
514 resource_name,
515 overrides: TransportOverrides::default(),
516 },
517 "already_exists" => Self::AlreadyExists {
518 ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
519 detail,
520 resource_type,
521 resource_name,
522 overrides: TransportOverrides::default(),
523 },
524 "permission_denied" => Self::PermissionDenied {
525 ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
526 detail,
527 resource_type,
528 resource_name,
529 overrides: TransportOverrides::default(),
530 },
531 "resource_exhausted" => Self::ResourceExhausted {
532 ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
533 detail,
534 resource_type,
535 resource_name,
536 overrides: TransportOverrides::default(),
537 },
538 "failed_precondition" => Self::FailedPrecondition {
539 ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
540 detail,
541 resource_type,
542 resource_name,
543 overrides: TransportOverrides::default(),
544 },
545 "aborted" => Self::Aborted {
546 ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
547 detail,
548 resource_type,
549 resource_name,
550 overrides: TransportOverrides::default(),
551 },
552 "out_of_range" => Self::OutOfRange {
553 ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
554 detail,
555 resource_type,
556 resource_name,
557 overrides: TransportOverrides::default(),
558 },
559 "unimplemented" => Self::Unimplemented {
560 ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
561 detail,
562 resource_type,
563 resource_name,
564 overrides: TransportOverrides::default(),
565 },
566 "internal" => Self::Internal {
567 ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
570 detail,
571 overrides: TransportOverrides::default(),
572 },
573 "service_unavailable" => Self::ServiceUnavailable {
574 ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
575 detail,
576 resource_type,
577 resource_name,
578 overrides: TransportOverrides::default(),
579 },
580 "data_loss" => Self::DataLoss {
581 ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
582 detail,
583 resource_type,
584 resource_name,
585 overrides: TransportOverrides::default(),
586 },
587 "unauthenticated" => Self::Unauthenticated {
588 ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
589 detail,
590 resource_type,
591 resource_name,
592 overrides: TransportOverrides::default(),
593 },
594 _ => {
595 return Err(ProblemConversionError::UnknownProblemType(
596 problem.problem_type,
597 ));
598 }
599 };
600
601 if !(100..=599).contains(&problem.status) {
602 return Err(ProblemConversionError::InvalidStatus(problem.status));
603 }
604
605 if !canonical.is_same_status_class(problem.status) {
606 return Err(ProblemConversionError::CategoryStatusMismatch {
607 category: canonical.category_name(),
608 status: problem.status,
609 });
610 }
611
612 if problem.status != canonical.default_http_status() {
616 canonical.transport_overrides_mut().http_status = Some(problem.status);
617 }
618
619 Ok(canonical)
620 }
621}
622
623#[cfg(feature = "axum")]
628fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
629 if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
630 return None;
631 }
632
633 ServiceUnavailable::deserialize(&problem.context)
634 .ok()?
635 .retry_after_seconds
636}
637
638#[cfg(feature = "axum")]
639impl axum::response::IntoResponse for Problem {
640 fn into_response(self) -> axum::response::Response {
641 match serde_json::to_vec(&self) {
642 Ok(body) => {
643 let status = http::StatusCode::from_u16(self.status)
644 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
645 let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
646 let mut response = (
647 status,
648 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
649 body,
650 )
651 .into_response();
652
653 if let Some(seconds) = retry_after_seconds {
654 response
655 .headers_mut()
656 .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
657 }
658
659 response
660 }
661 Err(e) => {
662 tracing::error!(
663 error = %e,
664 problem_type = %self.problem_type,
665 status = self.status,
666 "failed to serialize Problem; emitting fallback body",
667 );
668 let body = format!(
669 r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
670 PROBLEM_TYPE_PREFIX,
671 gts_type_prefix(),
672 GTS_TYPE_SUFFIX
673 );
674 (
675 http::StatusCode::INTERNAL_SERVER_ERROR,
676 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
677 body,
678 )
679 .into_response()
680 }
681 }
682 }
683}
684
685#[cfg(feature = "axum")]
686impl axum::response::IntoResponse for CanonicalError {
687 fn into_response(self) -> axum::response::Response {
688 let for_extension = self.clone();
695 let mut response = Problem::from(self).into_response();
696 response.extensions_mut().insert(for_extension);
697 response
698 }
699}
700
701#[cfg(feature = "utoipa")]
706impl utoipa::PartialSchema for Problem {
707 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
708 use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
709
710 ObjectBuilder::new()
711 .property(
712 "type",
713 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
714 )
715 .required("type")
716 .property(
717 "title",
718 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
719 )
720 .required("title")
721 .property(
722 "status",
723 ObjectBuilder::new()
724 .schema_type(SchemaType::Type(Type::Integer))
725 .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
726 )
727 .required("status")
728 .property(
729 "detail",
730 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
731 )
732 .required("detail")
733 .property(
734 "instance",
735 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
736 )
737 .property(
738 "trace_id",
739 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
740 )
741 .property(
742 "context",
743 ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
744 )
745 .required("context")
746 .description(Some(
747 "RFC 9457 problem+json. `context` varies by error category.",
748 ))
749 .into()
750 }
751}
752
753#[cfg(feature = "utoipa")]
754impl utoipa::ToSchema for Problem {
755 fn name() -> std::borrow::Cow<'static, str> {
756 std::borrow::Cow::Borrowed("Problem")
757 }
758}
759
760#[cfg(test)]
761#[cfg_attr(coverage_nightly, coverage(off))]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn not_found_round_trips_through_problem() {
767 let original = CanonicalError::__not_found(crate::context::NotFound::new())
768 .with_detail("invoice 42 missing")
769 .with_resource_type("invoice")
770 .with_resource("42");
771
772 let problem = Problem::from(original.clone());
773 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
774
775 assert!(matches!(recovered, CanonicalError::NotFound { .. }));
776 assert_eq!(recovered.detail(), original.detail());
777 assert_eq!(recovered.resource_type(), Some("invoice"));
778 assert_eq!(recovered.resource_name(), Some("42"));
779 }
780
781 #[test]
782 fn already_exists_round_trips_through_problem() {
783 let original = CanonicalError::__already_exists(crate::context::AlreadyExists::new())
784 .with_detail("duplicate payment")
785 .with_resource_type("payment")
786 .with_resource("xyz");
787
788 let problem = Problem::from(original);
789 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
790
791 assert!(matches!(recovered, CanonicalError::AlreadyExists { .. }));
792 assert_eq!(recovered.resource_type(), Some("payment"));
793 assert_eq!(recovered.resource_name(), Some("xyz"));
794 }
795
796 #[test]
797 fn permission_denied_round_trips_through_problem() {
798 let original = CanonicalError::__permission_denied(crate::context::PermissionDenied::new(
799 "missing scope",
800 ))
801 .with_detail("forbidden")
802 .with_resource_type("invoice")
803 .with_resource("42");
804
805 let problem = Problem::from(original);
806 let recovered = CanonicalError::try_from(problem).expect("known problem_type");
807
808 assert!(matches!(recovered, CanonicalError::PermissionDenied { .. }));
809 assert_eq!(recovered.resource_type(), Some("invoice"));
810 assert_eq!(recovered.resource_name(), Some("42"));
811 }
812
813 #[test]
814 fn unknown_problem_type_errors() {
815 let problem = Problem {
816 problem_type: "gts://something.else.unknown".to_owned(),
817 title: "X".to_owned(),
818 status: 500,
819 detail: String::new(),
820 instance: None,
821 trace_id: None,
822 context: serde_json::json!({}),
823 error_code: None,
824 error_domain: None,
825 };
826 assert!(CanonicalError::try_from(problem).is_err());
827 }
828}