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 pub const ALL: &'static [Self] = &[
62 Self::Cancelled,
63 Self::Unknown,
64 Self::InvalidArgument,
65 Self::DeadlineExceeded,
66 Self::NotFound,
67 Self::AlreadyExists,
68 Self::PermissionDenied,
69 Self::ResourceExhausted,
70 Self::FailedPrecondition,
71 Self::Aborted,
72 Self::OutOfRange,
73 Self::Unimplemented,
74 Self::Internal,
75 Self::ServiceUnavailable,
76 Self::DataLoss,
77 Self::Unauthenticated,
78 ];
79
80 #[must_use]
84 pub fn gts_fragment(self) -> &'static str {
85 match self {
86 Self::Cancelled => gts_id!("cf.core.errors.err.v1~cf.core.err.cancelled.v1~"),
87 Self::Unknown => gts_id!("cf.core.errors.err.v1~cf.core.err.unknown.v1~"),
88 Self::InvalidArgument => {
89 gts_id!("cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~")
90 }
91 Self::DeadlineExceeded => {
92 gts_id!("cf.core.errors.err.v1~cf.core.err.deadline_exceeded.v1~")
93 }
94 Self::NotFound => gts_id!("cf.core.errors.err.v1~cf.core.err.not_found.v1~"),
95 Self::AlreadyExists => {
96 gts_id!("cf.core.errors.err.v1~cf.core.err.already_exists.v1~")
97 }
98 Self::PermissionDenied => {
99 gts_id!("cf.core.errors.err.v1~cf.core.err.permission_denied.v1~")
100 }
101 Self::ResourceExhausted => {
102 gts_id!("cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~")
103 }
104 Self::FailedPrecondition => {
105 gts_id!("cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~")
106 }
107 Self::Aborted => gts_id!("cf.core.errors.err.v1~cf.core.err.aborted.v1~"),
108 Self::OutOfRange => gts_id!("cf.core.errors.err.v1~cf.core.err.out_of_range.v1~"),
109 Self::Unimplemented => {
110 gts_id!("cf.core.errors.err.v1~cf.core.err.unimplemented.v1~")
111 }
112 Self::Internal => gts_id!("cf.core.errors.err.v1~cf.core.err.internal.v1~"),
113 Self::ServiceUnavailable => {
114 gts_id!("cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~")
115 }
116 Self::DataLoss => gts_id!("cf.core.errors.err.v1~cf.core.err.data_loss.v1~"),
117 Self::Unauthenticated => {
118 gts_id!("cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~")
119 }
120 }
121 }
122
123 #[must_use]
125 #[allow(
126 clippy::match_same_arms,
127 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."
128 )]
129 pub fn http_status(self) -> u16 {
130 match self {
131 Self::Cancelled => 499,
132 Self::Unknown => 500,
133 Self::InvalidArgument => 400,
134 Self::DeadlineExceeded => 504,
135 Self::NotFound => 404,
136 Self::AlreadyExists => 409,
137 Self::PermissionDenied => 403,
138 Self::ResourceExhausted => 429,
139 Self::FailedPrecondition => 400,
140 Self::Aborted => 409,
141 Self::OutOfRange => 400,
142 Self::Unimplemented => 501,
143 Self::Internal => 500,
144 Self::ServiceUnavailable => 503,
145 Self::DataLoss => 500,
146 Self::Unauthenticated => 401,
147 }
148 }
149
150 #[must_use]
153 pub fn title(self) -> &'static str {
154 match self {
155 Self::Cancelled => "Cancelled",
156 Self::Unknown => "Unknown",
157 Self::InvalidArgument => "Invalid argument",
158 Self::DeadlineExceeded => "Deadline exceeded",
159 Self::NotFound => "Not found",
160 Self::AlreadyExists => "Already exists",
161 Self::PermissionDenied => "Permission denied",
162 Self::ResourceExhausted => "Resource exhausted",
163 Self::FailedPrecondition => "Failed precondition",
164 Self::Aborted => "Aborted",
165 Self::OutOfRange => "Out of range",
166 Self::Unimplemented => "Unimplemented",
167 Self::Internal => "Internal",
168 Self::ServiceUnavailable => "Service unavailable",
169 Self::DataLoss => "Data loss",
170 Self::Unauthenticated => "Unauthenticated",
171 }
172 }
173}
174
175const _: () = {
180 fn _assert_all_variants_listed(category: ProblemCategory) {
181 match category {
182 ProblemCategory::Cancelled
183 | ProblemCategory::Unknown
184 | ProblemCategory::InvalidArgument
185 | ProblemCategory::DeadlineExceeded
186 | ProblemCategory::NotFound
187 | ProblemCategory::AlreadyExists
188 | ProblemCategory::PermissionDenied
189 | ProblemCategory::ResourceExhausted
190 | ProblemCategory::FailedPrecondition
191 | ProblemCategory::Aborted
192 | ProblemCategory::OutOfRange
193 | ProblemCategory::Unimplemented
194 | ProblemCategory::Internal
195 | ProblemCategory::ServiceUnavailable
196 | ProblemCategory::DataLoss
197 | ProblemCategory::Unauthenticated => {}
198 }
199 }
200 assert!(ProblemCategory::ALL.len() == 16);
202};
203
204fn default_problem_type() -> String {
211 "about:blank".to_owned()
212}
213
214fn default_context() -> serde_json::Value {
221 serde_json::Value::Object(serde_json::Map::new())
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Problem {
234 #[serde(rename = "type", default = "default_problem_type")]
235 pub problem_type: String,
236 #[serde(default)]
237 pub title: String,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub status: Option<u16>,
240 #[serde(default)]
241 pub detail: String,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub instance: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub trace_id: Option<String>,
246 #[serde(default = "default_context")]
247 pub context: serde_json::Value,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
255 pub error_code: Option<String>,
256
257 #[serde(skip_serializing_if = "Option::is_none")]
261 pub error_domain: Option<String>,
262}
263
264impl Problem {
265 pub fn from_error(err: &CanonicalError) -> Result<Self, serde_json::Error> {
274 let problem_type = gts_uri!(err.gts_type());
275 let title = err.title().to_owned();
276 let status = err.status_code();
277 let detail = err.detail().to_owned();
278
279 let mut context = serialize_context(err)?;
280
281 if let Some(rt) = err.resource_type() {
282 context["resource_type"] = serde_json::Value::String(rt.to_owned());
283 }
284
285 if let Some(rn) = err.resource_name() {
286 context["resource_name"] = serde_json::Value::String(rn.to_owned());
287 }
288
289 Ok(Problem {
290 problem_type,
291 title,
292 status: Some(status),
293 detail,
294 instance: None,
295 trace_id: None,
296 context,
297 error_code: None,
298 error_domain: None,
299 })
300 }
301
302 #[must_use]
305 pub fn with_error_code(mut self, code: impl Into<String>) -> Self {
306 self.error_code = Some(code.into());
307 self
308 }
309
310 #[must_use]
312 pub fn with_error_domain(mut self, domain: impl Into<String>) -> Self {
313 self.error_domain = Some(domain.into());
314 self
315 }
316
317 pub fn contract_error(
328 category: ProblemCategory,
329 error_code: impl Into<String>,
330 error_domain: impl Into<String>,
331 detail: impl Into<String>,
332 data: serde_json::Value,
333 ) -> Self {
334 let mut context = serde_json::Map::new();
335 context.insert("data".to_owned(), data);
336 Problem {
337 problem_type: format!("gts://{}", category.gts_fragment()),
338 title: category.title().to_owned(),
339 status: Some(category.http_status()),
340 detail: detail.into(),
341 instance: None,
342 trace_id: None,
343 context: serde_json::Value::Object(context),
344 error_code: Some(error_code.into()),
345 error_domain: Some(error_domain.into()),
346 }
347 }
348
349 #[cfg(feature = "debug-problem")]
368 pub fn from_error_debug(err: &CanonicalError) -> Result<Self, serde_json::Error> {
369 let mut problem = Self::from_error(err)?;
370
371 if let Some(diag) = err.diagnostic() {
372 problem.context["description"] = serde_json::Value::String(diag.to_owned());
373 }
374
375 Ok(problem)
376 }
377
378 #[must_use]
380 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
381 self.trace_id = Some(trace_id.into());
382 self
383 }
384
385 #[must_use]
387 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
388 self.instance = Some(instance.into());
389 self
390 }
391}
392
393fn serialize_context(err: &CanonicalError) -> Result<serde_json::Value, serde_json::Error> {
394 match err {
395 CanonicalError::Cancelled { ctx, .. } => serde_json::to_value(ctx),
396 CanonicalError::Unknown { ctx, .. } => serde_json::to_value(ctx),
397 CanonicalError::InvalidArgument { ctx, .. } => serde_json::to_value(ctx),
398 CanonicalError::DeadlineExceeded { ctx, .. } => serde_json::to_value(ctx),
399 CanonicalError::NotFound { ctx, .. } => serde_json::to_value(ctx),
400 CanonicalError::AlreadyExists { ctx, .. } => serde_json::to_value(ctx),
401 CanonicalError::PermissionDenied { ctx, .. } => serde_json::to_value(ctx),
402 CanonicalError::ResourceExhausted { ctx, .. } => serde_json::to_value(ctx),
403 CanonicalError::FailedPrecondition { ctx, .. } => serde_json::to_value(ctx),
404 CanonicalError::Aborted { ctx, .. } => serde_json::to_value(ctx),
405 CanonicalError::OutOfRange { ctx, .. } => serde_json::to_value(ctx),
406 CanonicalError::Unimplemented { ctx, .. } => serde_json::to_value(ctx),
407 CanonicalError::Internal { ctx, .. } => serde_json::to_value(ctx),
408 CanonicalError::ServiceUnavailable { ctx, .. } => serde_json::to_value(ctx),
409 CanonicalError::DataLoss { ctx, .. } => serde_json::to_value(ctx),
410 CanonicalError::Unauthenticated { ctx, .. } => serde_json::to_value(ctx),
411 }
412}
413
414#[allow(unknown_lints, de1302_error_from_to_string)]
418impl From<CanonicalError> for Problem {
419 fn from(err: CanonicalError) -> Self {
420 match Problem::from_error(&err) {
421 Ok(p) => p,
422 Err(ser_err) => Problem {
423 problem_type: gts_uri!(err.gts_type()),
424 title: err.title().to_owned(),
425 status: Some(err.status_code()),
426 detail: err.detail().to_owned(),
427 instance: None,
428 trace_id: None,
429 context: serde_json::json!({ "serialization_error": ser_err.to_string() }),
430 error_code: None,
431 error_domain: None,
432 },
433 }
434 }
435}
436
437const PROBLEM_TYPE_PREFIX: &str = GTS_ID_URI_PREFIX;
457
458#[derive(Debug, thiserror::Error)]
460pub enum ProblemConversionError {
461 #[error("unrecognized problem_type: {0}")]
465 UnknownProblemType(String),
466
467 #[error("invalid context for canonical category {category}: {source}")]
471 InvalidContext {
472 category: &'static str,
473 #[source]
474 source: serde_json::Error,
475 },
476
477 #[error("invalid HTTP status in Problem: {0}")]
482 InvalidStatus(u16),
483
484 #[error("status {status} is a different HTTP status class than category {category}'s default")]
490 CategoryStatusMismatch { category: &'static str, status: u16 },
491}
492
493#[allow(unknown_lints, de0901_gts_string_pattern)]
498fn gts_type_prefix() -> &'static str {
508 use std::sync::OnceLock;
509 static PREFIX: OnceLock<String> = OnceLock::new();
510 PREFIX.get_or_init(|| format!("{GTS_ID_PREFIX}cf.core.errors.err.v1~cf.core.err."))
511}
512const GTS_TYPE_SUFFIX: &str = ".v1~";
514
515fn category_from_problem_type(problem_type: &str) -> Option<&str> {
518 let rest = problem_type.strip_prefix(PROBLEM_TYPE_PREFIX)?;
519 let after_prefix = rest.strip_prefix(gts_type_prefix())?;
520 after_prefix.strip_suffix(GTS_TYPE_SUFFIX)
521}
522
523fn extract_resource_fields(context: &serde_json::Value) -> (Option<String>, Option<String>) {
529 let resource_type = context
530 .get("resource_type")
531 .and_then(serde_json::Value::as_str)
532 .map(str::to_owned);
533 let resource_name = context
534 .get("resource_name")
535 .and_then(serde_json::Value::as_str)
536 .map(str::to_owned);
537 (resource_type, resource_name)
538}
539
540fn deserialize_ctx<T>(
541 category: &'static str,
542 context: serde_json::Value,
543) -> Result<T, ProblemConversionError>
544where
545 T: serde::de::DeserializeOwned,
546{
547 serde_json::from_value(context)
548 .map_err(|source| ProblemConversionError::InvalidContext { category, source })
549}
550
551impl TryFrom<Problem> for CanonicalError {
552 type Error = ProblemConversionError;
553
554 fn try_from(problem: Problem) -> Result<Self, Self::Error> {
555 let category = category_from_problem_type(&problem.problem_type).ok_or_else(|| {
556 ProblemConversionError::UnknownProblemType(problem.problem_type.clone())
557 })?;
558
559 let detail = problem.detail;
560 let (resource_type, resource_name) = extract_resource_fields(&problem.context);
561 let ctx_value = problem.context;
562
563 let mut canonical = match category {
564 "cancelled" => Self::Cancelled {
565 ctx: deserialize_ctx::<Cancelled>("cancelled", ctx_value)?,
566 detail,
567 resource_type,
568 resource_name,
569 overrides: TransportOverrides::default(),
570 },
571 "unknown" => Self::Unknown {
572 ctx: deserialize_ctx::<Unknown>("unknown", ctx_value)?,
573 detail,
574 resource_type,
575 resource_name,
576 overrides: TransportOverrides::default(),
577 },
578 "invalid_argument" => Self::InvalidArgument {
579 ctx: deserialize_ctx::<InvalidArgument>("invalid_argument", ctx_value)?,
580 detail,
581 resource_type,
582 resource_name,
583 overrides: TransportOverrides::default(),
584 },
585 "deadline_exceeded" => Self::DeadlineExceeded {
586 ctx: deserialize_ctx::<DeadlineExceeded>("deadline_exceeded", ctx_value)?,
587 detail,
588 resource_type,
589 resource_name,
590 overrides: TransportOverrides::default(),
591 },
592 "not_found" => Self::NotFound {
593 ctx: deserialize_ctx::<NotFound>("not_found", ctx_value)?,
594 detail,
595 resource_type,
596 resource_name,
597 overrides: TransportOverrides::default(),
598 },
599 "already_exists" => Self::AlreadyExists {
600 ctx: deserialize_ctx::<AlreadyExists>("already_exists", ctx_value)?,
601 detail,
602 resource_type,
603 resource_name,
604 overrides: TransportOverrides::default(),
605 },
606 "permission_denied" => Self::PermissionDenied {
607 ctx: deserialize_ctx::<PermissionDenied>("permission_denied", ctx_value)?,
608 detail,
609 resource_type,
610 resource_name,
611 overrides: TransportOverrides::default(),
612 },
613 "resource_exhausted" => Self::ResourceExhausted {
614 ctx: deserialize_ctx::<ResourceExhausted>("resource_exhausted", ctx_value)?,
615 detail,
616 resource_type,
617 resource_name,
618 overrides: TransportOverrides::default(),
619 },
620 "failed_precondition" => Self::FailedPrecondition {
621 ctx: deserialize_ctx::<FailedPrecondition>("failed_precondition", ctx_value)?,
622 detail,
623 resource_type,
624 resource_name,
625 overrides: TransportOverrides::default(),
626 },
627 "aborted" => Self::Aborted {
628 ctx: deserialize_ctx::<Aborted>("aborted", ctx_value)?,
629 detail,
630 resource_type,
631 resource_name,
632 overrides: TransportOverrides::default(),
633 },
634 "out_of_range" => Self::OutOfRange {
635 ctx: deserialize_ctx::<OutOfRange>("out_of_range", ctx_value)?,
636 detail,
637 resource_type,
638 resource_name,
639 overrides: TransportOverrides::default(),
640 },
641 "unimplemented" => Self::Unimplemented {
642 ctx: deserialize_ctx::<Unimplemented>("unimplemented", ctx_value)?,
643 detail,
644 resource_type,
645 resource_name,
646 overrides: TransportOverrides::default(),
647 },
648 "internal" => Self::Internal {
649 ctx: deserialize_ctx::<Internal>("internal", ctx_value)?,
652 detail,
653 overrides: TransportOverrides::default(),
654 },
655 "service_unavailable" => Self::ServiceUnavailable {
656 ctx: deserialize_ctx::<ServiceUnavailable>("service_unavailable", ctx_value)?,
657 detail,
658 resource_type,
659 resource_name,
660 overrides: TransportOverrides::default(),
661 },
662 "data_loss" => Self::DataLoss {
663 ctx: deserialize_ctx::<DataLoss>("data_loss", ctx_value)?,
664 detail,
665 resource_type,
666 resource_name,
667 overrides: TransportOverrides::default(),
668 },
669 "unauthenticated" => Self::Unauthenticated {
670 ctx: deserialize_ctx::<Unauthenticated>("unauthenticated", ctx_value)?,
671 detail,
672 resource_type,
673 resource_name,
674 overrides: TransportOverrides::default(),
675 },
676 _ => {
677 return Err(ProblemConversionError::UnknownProblemType(
678 problem.problem_type,
679 ));
680 }
681 };
682
683 if let Some(status) = problem.status {
686 if !(100..=599).contains(&status) {
687 return Err(ProblemConversionError::InvalidStatus(status));
688 }
689
690 if !canonical.is_same_status_class(status) {
691 return Err(ProblemConversionError::CategoryStatusMismatch {
692 category: canonical.category_name(),
693 status,
694 });
695 }
696
697 if status != canonical.default_http_status() {
702 canonical.transport_overrides_mut().http_status = Some(status);
703 }
704 }
705
706 Ok(canonical)
707 }
708}
709
710#[cfg(feature = "axum")]
715fn service_unavailable_retry_after_seconds(problem: &Problem) -> Option<u64> {
716 if category_from_problem_type(&problem.problem_type) != Some("service_unavailable") {
717 return None;
718 }
719
720 ServiceUnavailable::deserialize(&problem.context)
721 .ok()?
722 .retry_after_seconds
723}
724
725#[cfg(feature = "axum")]
726impl axum::response::IntoResponse for Problem {
727 fn into_response(self) -> axum::response::Response {
728 match serde_json::to_vec(&self) {
729 Ok(body) => {
730 let status = self
731 .status
732 .and_then(|s| http::StatusCode::from_u16(s).ok())
733 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR);
734 let retry_after_seconds = service_unavailable_retry_after_seconds(&self);
735 let mut response = (
736 status,
737 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
738 body,
739 )
740 .into_response();
741
742 if let Some(seconds) = retry_after_seconds {
743 response
744 .headers_mut()
745 .insert(http::header::RETRY_AFTER, http::HeaderValue::from(seconds));
746 }
747
748 response
749 }
750 Err(e) => {
751 tracing::error!(
752 error = %e,
753 problem_type = %self.problem_type,
754 status = ?self.status,
755 "failed to serialize Problem; emitting fallback body",
756 );
757 let body = format!(
758 r#"{{"type":"{}{}internal{}","title":"Internal","status":500,"detail":"failed to serialize problem","context":{{}}}}"#,
759 PROBLEM_TYPE_PREFIX,
760 gts_type_prefix(),
761 GTS_TYPE_SUFFIX
762 );
763 (
764 http::StatusCode::INTERNAL_SERVER_ERROR,
765 [(http::header::CONTENT_TYPE, APPLICATION_PROBLEM_JSON)],
766 body,
767 )
768 .into_response()
769 }
770 }
771 }
772}
773
774#[cfg(feature = "axum")]
775impl axum::response::IntoResponse for CanonicalError {
776 fn into_response(self) -> axum::response::Response {
777 let for_extension = self.clone();
784 let mut response = Problem::from(self).into_response();
785 response.extensions_mut().insert(for_extension);
786 response
787 }
788}
789
790#[cfg(feature = "utoipa")]
795impl utoipa::PartialSchema for Problem {
796 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
797 use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, SchemaType, Type};
798
799 ObjectBuilder::new()
800 .property(
801 "type",
802 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
803 )
804 .required("type")
805 .property(
806 "title",
807 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
808 )
809 .required("title")
810 .property(
811 "status",
812 ObjectBuilder::new()
813 .schema_type(SchemaType::Type(Type::Integer))
814 .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))),
815 )
816 .required("status")
817 .property(
818 "detail",
819 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
820 )
821 .required("detail")
822 .property(
823 "instance",
824 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
825 )
826 .property(
827 "trace_id",
828 ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)),
829 )
830 .property(
831 "context",
832 ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)),
833 )
834 .required("context")
835 .description(Some(
836 "RFC 9457 problem+json. `context` varies by error category.",
837 ))
838 .into()
839 }
840}
841
842#[cfg(feature = "utoipa")]
843impl utoipa::ToSchema for Problem {
844 fn name() -> std::borrow::Cow<'static, str> {
845 std::borrow::Cow::Borrowed("Problem")
846 }
847}
848
849#[cfg(test)]
850#[cfg_attr(coverage_nightly, coverage(off))]
851#[path = "problem_tests.rs"]
852mod tests;