1#[cfg(all(not(feature = "std"), feature = "alloc", feature = "serde"))]
31use alloc::collections::BTreeMap;
32#[cfg(all(not(feature = "std"), feature = "alloc"))]
33use alloc::{borrow::ToOwned, format, string::String, string::ToString, sync::Arc, vec::Vec};
34use core::fmt;
35#[cfg(all(feature = "std", not(feature = "serde")))]
36use std::sync::Arc;
37#[cfg(all(feature = "std", feature = "serde"))]
38use std::{collections::BTreeMap, sync::Arc};
39
40#[cfg(feature = "serde")]
41use serde::{Deserialize, Serialize};
42
43#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
65#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
66#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
67pub enum ErrorCode {
68 BadRequest,
70 ValidationFailed,
71 Unauthorized,
73 InvalidCredentials,
74 TokenExpired,
75 TokenInvalid,
76 Forbidden,
78 InsufficientPermissions,
79 OrgOutsideSubtree,
80 AncestorRequired,
81 CrossSubtreeAccess,
82 ResourceNotFound,
84 MethodNotAllowed,
86 NotAcceptable,
88 RequestTimeout,
90 Conflict,
92 ResourceAlreadyExists,
93 Gone,
95 PreconditionFailed,
97 PayloadTooLarge,
99 UnsupportedMediaType,
101 UnprocessableEntity,
103 PreconditionRequired,
105 RateLimited,
107 RequestHeaderFieldsTooLarge,
109 InternalServerError,
111 NotImplemented,
113 BadGateway,
115 ServiceUnavailable,
117 GatewayTimeout,
119}
120
121#[cfg(any(feature = "std", feature = "alloc"))]
167#[derive(Debug, Clone, PartialEq, Eq)]
168#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
169#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
170#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
171pub enum ErrorTypeMode {
172 Url {
175 base_url: String,
177 },
178 Urn {
181 namespace: String,
183 },
184}
185
186#[cfg(any(feature = "std", feature = "alloc"))]
187impl ErrorTypeMode {
188 #[must_use]
202 pub fn render(&self, slug: &str) -> String {
203 match self {
204 Self::Url { base_url } => format!("{}/{slug}", base_url.trim_end_matches('/')),
205 Self::Urn { namespace } => format!("urn:{namespace}:error:{slug}"),
206 }
207 }
208}
209
210#[cfg(feature = "std")]
222static ERROR_TYPE_MODE: std::sync::RwLock<Option<ErrorTypeMode>> = std::sync::RwLock::new(None);
223
224#[cfg(feature = "std")]
226fn resolve_error_type_mode() -> ErrorTypeMode {
227 #[cfg(not(coverage))]
230 if let Some(url) = option_env!("SHARED_TYPES_ERROR_TYPE_BASE_URL")
231 && !url.is_empty()
232 {
233 return ErrorTypeMode::Url {
234 base_url: url.to_owned(),
235 };
236 }
237 if let Ok(url) = std::env::var("SHARED_TYPES_ERROR_TYPE_BASE_URL")
239 && !url.is_empty()
240 {
241 return ErrorTypeMode::Url { base_url: url };
242 }
243 #[cfg(not(coverage))]
245 if let Some(ns) = option_env!("SHARED_TYPES_URN_NAMESPACE")
246 && !ns.is_empty()
247 {
248 return ErrorTypeMode::Urn {
249 namespace: ns.to_owned(),
250 };
251 }
252 if let Ok(ns) = std::env::var("SHARED_TYPES_URN_NAMESPACE")
254 && !ns.is_empty()
255 {
256 return ErrorTypeMode::Urn { namespace: ns };
257 }
258 ErrorTypeMode::Urn {
260 namespace: "api-bones".to_owned(),
261 }
262}
263
264#[cfg(feature = "std")]
287#[must_use]
288pub fn error_type_mode() -> ErrorTypeMode {
289 {
290 let guard = ERROR_TYPE_MODE
291 .read()
292 .expect("error type mode lock poisoned");
293 if let Some(mode) = guard.as_ref() {
294 return mode.clone();
295 }
296 }
297 let mut guard = ERROR_TYPE_MODE
299 .write()
300 .expect("error type mode lock poisoned");
301 if let Some(mode) = guard.as_ref() {
303 return mode.clone();
304 }
305 let mode = resolve_error_type_mode();
306 *guard = Some(mode.clone());
307 mode
308}
309
310#[cfg(feature = "std")]
326pub fn set_error_type_mode(mode: ErrorTypeMode) {
327 let mut guard = ERROR_TYPE_MODE
328 .write()
329 .expect("error type mode lock poisoned");
330 *guard = Some(mode);
331}
332
333#[cfg(all(test, feature = "std"))]
338pub(crate) fn reset_error_type_mode() {
339 let mut guard = ERROR_TYPE_MODE
340 .write()
341 .expect("error type mode lock poisoned");
342 *guard = None;
343}
344
345#[cfg(feature = "std")]
359#[must_use]
360pub fn urn_namespace() -> String {
361 match error_type_mode() {
362 ErrorTypeMode::Urn { namespace } => namespace,
363 ErrorTypeMode::Url { .. } => "api-bones".to_owned(),
364 }
365}
366
367impl ErrorCode {
368 #[must_use]
380 pub fn status_code(&self) -> u16 {
381 match self {
382 Self::BadRequest | Self::ValidationFailed => 400,
383 Self::Unauthorized
384 | Self::InvalidCredentials
385 | Self::TokenExpired
386 | Self::TokenInvalid => 401,
387 Self::Forbidden
388 | Self::InsufficientPermissions
389 | Self::OrgOutsideSubtree
390 | Self::AncestorRequired
391 | Self::CrossSubtreeAccess => 403,
392 Self::ResourceNotFound => 404,
393 Self::MethodNotAllowed => 405,
394 Self::NotAcceptable => 406,
395 Self::RequestTimeout => 408,
396 Self::Conflict | Self::ResourceAlreadyExists => 409,
397 Self::Gone => 410,
398 Self::PreconditionFailed => 412,
399 Self::PayloadTooLarge => 413,
400 Self::UnsupportedMediaType => 415,
401 Self::UnprocessableEntity => 422,
402 Self::PreconditionRequired => 428,
403 Self::RateLimited => 429,
404 Self::RequestHeaderFieldsTooLarge => 431,
405 Self::InternalServerError => 500,
406 Self::NotImplemented => 501,
407 Self::BadGateway => 502,
408 Self::ServiceUnavailable => 503,
409 Self::GatewayTimeout => 504,
410 }
411 }
412
413 #[must_use]
424 pub fn title(&self) -> &'static str {
425 match self {
426 Self::BadRequest => "Bad Request",
427 Self::ValidationFailed => "Validation Failed",
428 Self::Unauthorized => "Unauthorized",
429 Self::InvalidCredentials => "Invalid Credentials",
430 Self::TokenExpired => "Token Expired",
431 Self::TokenInvalid => "Token Invalid",
432 Self::Forbidden => "Forbidden",
433 Self::InsufficientPermissions => "Insufficient Permissions",
434 Self::OrgOutsideSubtree => "Org Outside Subtree",
435 Self::AncestorRequired => "Ancestor Required",
436 Self::CrossSubtreeAccess => "Cross Subtree Access",
437 Self::ResourceNotFound => "Resource Not Found",
438 Self::MethodNotAllowed => "Method Not Allowed",
439 Self::NotAcceptable => "Not Acceptable",
440 Self::RequestTimeout => "Request Timeout",
441 Self::Conflict => "Conflict",
442 Self::ResourceAlreadyExists => "Resource Already Exists",
443 Self::Gone => "Gone",
444 Self::PreconditionFailed => "Precondition Failed",
445 Self::PayloadTooLarge => "Payload Too Large",
446 Self::UnsupportedMediaType => "Unsupported Media Type",
447 Self::UnprocessableEntity => "Unprocessable Entity",
448 Self::PreconditionRequired => "Precondition Required",
449 Self::RateLimited => "Rate Limited",
450 Self::RequestHeaderFieldsTooLarge => "Request Header Fields Too Large",
451 Self::InternalServerError => "Internal Server Error",
452 Self::NotImplemented => "Not Implemented",
453 Self::BadGateway => "Bad Gateway",
454 Self::ServiceUnavailable => "Service Unavailable",
455 Self::GatewayTimeout => "Gateway Timeout",
456 }
457 }
458
459 #[must_use]
470 pub fn urn_slug(&self) -> &'static str {
471 match self {
472 Self::BadRequest => "bad-request",
473 Self::ValidationFailed => "validation-failed",
474 Self::Unauthorized => "unauthorized",
475 Self::InvalidCredentials => "invalid-credentials",
476 Self::TokenExpired => "token-expired",
477 Self::TokenInvalid => "token-invalid",
478 Self::Forbidden => "forbidden",
479 Self::InsufficientPermissions => "insufficient-permissions",
480 Self::OrgOutsideSubtree => "org-outside-subtree",
481 Self::AncestorRequired => "ancestor-required",
482 Self::CrossSubtreeAccess => "cross-subtree-access",
483 Self::ResourceNotFound => "resource-not-found",
484 Self::MethodNotAllowed => "method-not-allowed",
485 Self::NotAcceptable => "not-acceptable",
486 Self::RequestTimeout => "request-timeout",
487 Self::Conflict => "conflict",
488 Self::ResourceAlreadyExists => "resource-already-exists",
489 Self::Gone => "gone",
490 Self::PreconditionFailed => "precondition-failed",
491 Self::PayloadTooLarge => "payload-too-large",
492 Self::UnsupportedMediaType => "unsupported-media-type",
493 Self::UnprocessableEntity => "unprocessable-entity",
494 Self::PreconditionRequired => "precondition-required",
495 Self::RateLimited => "rate-limited",
496 Self::RequestHeaderFieldsTooLarge => "request-header-fields-too-large",
497 Self::InternalServerError => "internal-server-error",
498 Self::NotImplemented => "not-implemented",
499 Self::BadGateway => "bad-gateway",
500 Self::ServiceUnavailable => "service-unavailable",
501 Self::GatewayTimeout => "gateway-timeout",
502 }
503 }
504
505 #[cfg(feature = "std")]
522 #[must_use]
523 pub fn urn(&self) -> String {
524 error_type_mode().render(self.urn_slug())
525 }
526
527 #[cfg(feature = "std")]
542 #[must_use]
543 pub fn from_type_uri(s: &str) -> Option<Self> {
544 let slug = match error_type_mode() {
546 ErrorTypeMode::Url { base_url } => {
547 let prefix = format!("{}/", base_url.trim_end_matches('/'));
548 s.strip_prefix(prefix.as_str()).or_else(|| {
549 let urn_prefix = format!("urn:{}:error:", urn_namespace());
551 s.strip_prefix(urn_prefix.as_str())
552 })?
553 }
554 ErrorTypeMode::Urn { namespace } => {
555 let prefix = format!("urn:{namespace}:error:");
556 s.strip_prefix(prefix.as_str())?
557 }
558 };
559 Some(match slug {
560 "bad-request" => Self::BadRequest,
561 "validation-failed" => Self::ValidationFailed,
562 "unauthorized" => Self::Unauthorized,
563 "invalid-credentials" => Self::InvalidCredentials,
564 "token-expired" => Self::TokenExpired,
565 "token-invalid" => Self::TokenInvalid,
566 "forbidden" => Self::Forbidden,
567 "insufficient-permissions" => Self::InsufficientPermissions,
568 "org-outside-subtree" => Self::OrgOutsideSubtree,
569 "ancestor-required" => Self::AncestorRequired,
570 "cross-subtree-access" => Self::CrossSubtreeAccess,
571 "resource-not-found" => Self::ResourceNotFound,
572 "method-not-allowed" => Self::MethodNotAllowed,
573 "not-acceptable" => Self::NotAcceptable,
574 "request-timeout" => Self::RequestTimeout,
575 "conflict" => Self::Conflict,
576 "resource-already-exists" => Self::ResourceAlreadyExists,
577 "gone" => Self::Gone,
578 "precondition-failed" => Self::PreconditionFailed,
579 "payload-too-large" => Self::PayloadTooLarge,
580 "unsupported-media-type" => Self::UnsupportedMediaType,
581 "unprocessable-entity" => Self::UnprocessableEntity,
582 "precondition-required" => Self::PreconditionRequired,
583 "rate-limited" => Self::RateLimited,
584 "request-header-fields-too-large" => Self::RequestHeaderFieldsTooLarge,
585 "internal-server-error" => Self::InternalServerError,
586 "not-implemented" => Self::NotImplemented,
587 "bad-gateway" => Self::BadGateway,
588 "service-unavailable" => Self::ServiceUnavailable,
589 "gateway-timeout" => Self::GatewayTimeout,
590 _ => return None,
591 })
592 }
593}
594
595#[cfg(feature = "std")]
597impl fmt::Display for ErrorCode {
598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599 f.write_str(&self.urn())
600 }
601}
602
603#[cfg(not(feature = "std"))]
605impl fmt::Display for ErrorCode {
606 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607 write!(f, "urn:api-bones:error:{}", self.urn_slug())
608 }
609}
610
611#[cfg(all(feature = "serde", feature = "std"))]
612impl Serialize for ErrorCode {
613 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
614 s.serialize_str(&self.urn())
615 }
616}
617
618#[cfg(all(feature = "serde", feature = "std"))]
619impl<'de> Deserialize<'de> for ErrorCode {
620 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
621 let s = String::deserialize(d)?;
622 Self::from_type_uri(&s)
623 .ok_or_else(|| serde::de::Error::custom(format!("unknown error type URI: {s}")))
624 }
625}
626
627#[cfg(feature = "utoipa")]
628impl utoipa::PartialSchema for ErrorCode {
629 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
630 use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
631 utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
632 ObjectBuilder::new()
633 .schema_type(SchemaType::new(Type::String))
634 .examples(["urn:api-bones:error:resource-not-found"])
635 .build(),
636 ))
637 }
638}
639
640#[cfg(feature = "utoipa")]
641impl utoipa::ToSchema for ErrorCode {
642 fn name() -> std::borrow::Cow<'static, str> {
643 std::borrow::Cow::Borrowed("ErrorCode")
644 }
645}
646
647impl TryFrom<u16> for ErrorCode {
668 type Error = ();
669
670 fn try_from(status: u16) -> Result<Self, Self::Error> {
671 match status {
672 400 => Ok(Self::BadRequest),
673 401 => Ok(Self::Unauthorized),
674 403 => Ok(Self::Forbidden),
675 404 => Ok(Self::ResourceNotFound),
676 405 => Ok(Self::MethodNotAllowed),
677 406 => Ok(Self::NotAcceptable),
678 408 => Ok(Self::RequestTimeout),
679 409 => Ok(Self::Conflict),
680 410 => Ok(Self::Gone),
681 412 => Ok(Self::PreconditionFailed),
682 413 => Ok(Self::PayloadTooLarge),
683 415 => Ok(Self::UnsupportedMediaType),
684 422 => Ok(Self::UnprocessableEntity),
685 428 => Ok(Self::PreconditionRequired),
686 429 => Ok(Self::RateLimited),
687 431 => Ok(Self::RequestHeaderFieldsTooLarge),
688 500 => Ok(Self::InternalServerError),
689 501 => Ok(Self::NotImplemented),
690 502 => Ok(Self::BadGateway),
691 503 => Ok(Self::ServiceUnavailable),
692 504 => Ok(Self::GatewayTimeout),
693 _ => Err(()),
694 }
695 }
696}
697
698#[cfg(feature = "http")]
720impl TryFrom<http::StatusCode> for ErrorCode {
721 type Error = ();
722
723 fn try_from(status: http::StatusCode) -> Result<Self, Self::Error> {
724 Self::try_from(status.as_u16())
725 }
726}
727
728#[cfg(any(feature = "std", feature = "alloc"))]
759#[derive(Debug, Clone, PartialEq, Eq)]
760#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
761#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
763#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
764#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
765pub struct ValidationError {
766 pub field: String,
768 pub message: String,
770 #[cfg_attr(
772 feature = "serde",
773 serde(default, skip_serializing_if = "Option::is_none")
774 )]
775 pub rule: Option<String>,
776}
777
778#[cfg(any(feature = "std", feature = "alloc"))]
779impl fmt::Display for ValidationError {
780 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
781 match &self.rule {
782 Some(rule) => write!(f, "{}: {} (rule: {})", self.field, self.message, rule),
783 None => write!(f, "{}: {}", self.field, self.message),
784 }
785 }
786}
787
788#[cfg(any(feature = "std", feature = "alloc"))]
789impl core::error::Error for ValidationError {}
790
791#[cfg(any(feature = "std", feature = "alloc"))]
817pub trait HttpError: core::fmt::Debug {
818 fn status_code(&self) -> u16;
820 fn error_code(&self) -> ErrorCode;
822 fn detail(&self) -> String;
824}
825
826#[cfg(any(feature = "std", feature = "alloc"))]
831impl<E: HttpError> From<E> for ApiError {
832 fn from(e: E) -> Self {
833 Self::new(e.error_code(), e.detail())
834 }
835}
836
837#[cfg(any(feature = "std", feature = "alloc"))]
866#[derive(Debug, Clone)]
867#[cfg_attr(
868 all(feature = "std", feature = "serde"),
869 derive(Serialize, Deserialize)
870)]
871#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
874pub struct ApiError {
875 #[cfg_attr(all(feature = "std", feature = "serde"), serde(rename = "type"))]
877 pub code: ErrorCode,
878 pub title: String,
880 pub status: u16,
882 pub detail: String,
884 #[cfg(feature = "uuid")]
887 #[cfg_attr(
888 all(feature = "std", feature = "serde"),
889 serde(
890 rename = "instance",
891 default,
892 skip_serializing_if = "Option::is_none",
893 with = "uuid_urn_option"
894 )
895 )]
896 #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
897 pub request_id: Option<uuid::Uuid>,
898 #[cfg_attr(
900 all(feature = "std", feature = "serde"),
901 serde(default, skip_serializing_if = "Vec::is_empty")
902 )]
903 pub errors: Vec<ValidationError>,
904 #[cfg_attr(
909 all(feature = "std", feature = "serde"),
910 serde(default, skip_serializing_if = "Option::is_none")
911 )]
912 #[cfg_attr(feature = "arbitrary", arbitrary(default))]
913 pub rate_limit: Option<crate::ratelimit::RateLimitInfo>,
914 #[cfg(any(feature = "std", feature = "alloc"))]
922 #[cfg_attr(all(feature = "std", feature = "serde"), serde(skip))]
923 #[cfg_attr(feature = "utoipa", schema(value_type = (), ignore))]
924 #[cfg_attr(feature = "schemars", schemars(skip))]
925 #[cfg_attr(feature = "arbitrary", arbitrary(default))]
926 pub source: Option<Arc<dyn core::error::Error + Send + Sync + 'static>>,
927 #[cfg_attr(
932 all(feature = "std", feature = "serde"),
933 serde(default, skip_serializing_if = "Vec::is_empty")
934 )]
935 #[cfg_attr(feature = "arbitrary", arbitrary(default))]
936 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<Object>))]
939 pub causes: Vec<Self>,
940 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
947 #[cfg_attr(all(feature = "std", feature = "serde"), serde(flatten))]
948 #[cfg_attr(feature = "arbitrary", arbitrary(default))]
949 pub extensions: BTreeMap<String, serde_json::Value>,
950}
951
952#[cfg(any(feature = "std", feature = "alloc"))]
953impl PartialEq for ApiError {
954 fn eq(&self, other: &Self) -> bool {
955 self.code == other.code
958 && self.title == other.title
959 && self.status == other.status
960 && self.detail == other.detail
961 && self.errors == other.errors
962 && self.rate_limit == other.rate_limit
963 && self.causes == other.causes
964 && {
966 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
967 { self.extensions == other.extensions }
968 #[cfg(not(all(any(feature = "std", feature = "alloc"), feature = "serde")))]
969 true
970 }
971 && {
973 #[cfg(feature = "uuid")]
974 { self.request_id == other.request_id }
975 #[cfg(not(feature = "uuid"))]
976 true
977 }
978 }
979}
980
981#[cfg(all(
984 feature = "serde",
985 feature = "uuid",
986 any(feature = "std", feature = "alloc")
987))]
988mod uuid_urn_option {
989 use serde::{Deserialize, Deserializer, Serializer};
990
991 #[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(uuid: &Option<uuid::Uuid>, s: S) -> Result<S::Ok, S::Error> {
993 match uuid {
994 Some(id) => s.serialize_str(&format!("urn:uuid:{id}")),
995 None => s.serialize_none(),
996 }
997 }
998
999 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<uuid::Uuid>, D::Error> {
1000 let opt = Option::<String>::deserialize(d)?;
1001 match opt {
1002 None => Ok(None),
1003 Some(ref urn) => {
1004 let hex = urn.strip_prefix("urn:uuid:").ok_or_else(|| {
1005 serde::de::Error::custom(format!("expected urn:uuid: prefix, got {urn}"))
1006 })?;
1007 hex.parse::<uuid::Uuid>()
1008 .map(Some)
1009 .map_err(serde::de::Error::custom)
1010 }
1011 }
1012 }
1013}
1014
1015#[cfg(any(feature = "std", feature = "alloc"))]
1016impl ApiError {
1017 pub fn new(code: ErrorCode, detail: impl Into<String>) -> Self {
1030 let status = code.status_code();
1031 debug_assert!(
1032 (100..=599).contains(&status),
1033 "status {status} is not a valid HTTP status code (RFC 9457 §3.1.3 requires 100–599)"
1034 );
1035 Self {
1036 title: code.title().to_owned(),
1037 status,
1038 detail: detail.into(),
1039 code,
1040 #[cfg(feature = "uuid")]
1041 request_id: None,
1042 errors: Vec::new(),
1043 rate_limit: None,
1044 source: None,
1045 causes: Vec::new(),
1046 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1047 extensions: BTreeMap::new(),
1048 }
1049 }
1050
1051 #[cfg(feature = "uuid")]
1065 #[must_use]
1066 pub fn with_request_id(mut self, id: uuid::Uuid) -> Self {
1067 self.request_id = Some(id);
1068 self
1069 }
1070
1071 #[must_use]
1085 pub fn with_errors(mut self, errors: Vec<ValidationError>) -> Self {
1086 self.errors = errors;
1087 self
1088 }
1089
1090 #[cfg(any(feature = "std", feature = "alloc"))]
1097 #[must_use]
1098 pub fn with_source(mut self, source: impl core::error::Error + Send + Sync + 'static) -> Self {
1099 self.source = Some(Arc::new(source));
1100 self
1101 }
1102
1103 #[must_use]
1117 pub fn with_causes(mut self, causes: Vec<Self>) -> Self {
1118 self.causes = causes;
1119 self
1120 }
1121
1122 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1138 #[must_use]
1139 pub fn with_extension(
1140 mut self,
1141 key: impl Into<String>,
1142 value: impl Into<serde_json::Value>,
1143 ) -> Self {
1144 self.extensions.insert(key.into(), value.into());
1145 self
1146 }
1147
1148 #[must_use]
1150 pub fn status_code(&self) -> u16 {
1151 self.status
1152 }
1153
1154 #[must_use]
1165 pub fn is_client_error(&self) -> bool {
1166 self.status < 500
1167 }
1168
1169 #[must_use]
1180 pub fn is_server_error(&self) -> bool {
1181 self.status >= 500
1182 }
1183
1184 pub fn bad_request(msg: impl Into<String>) -> Self {
1200 Self::new(ErrorCode::BadRequest, msg)
1201 }
1202
1203 pub fn validation_failed(msg: impl Into<String>) -> Self {
1205 Self::new(ErrorCode::ValidationFailed, msg)
1206 }
1207
1208 pub fn unauthorized(msg: impl Into<String>) -> Self {
1210 Self::new(ErrorCode::Unauthorized, msg)
1211 }
1212
1213 #[must_use]
1215 pub fn invalid_credentials() -> Self {
1216 Self::new(ErrorCode::InvalidCredentials, "Invalid credentials")
1217 }
1218
1219 #[must_use]
1221 pub fn token_expired() -> Self {
1222 Self::new(ErrorCode::TokenExpired, "Token has expired")
1223 }
1224
1225 pub fn forbidden(msg: impl Into<String>) -> Self {
1227 Self::new(ErrorCode::Forbidden, msg)
1228 }
1229
1230 pub fn insufficient_permissions(msg: impl Into<String>) -> Self {
1232 Self::new(ErrorCode::InsufficientPermissions, msg)
1233 }
1234
1235 pub fn not_found(msg: impl Into<String>) -> Self {
1247 Self::new(ErrorCode::ResourceNotFound, msg)
1248 }
1249
1250 pub fn conflict(msg: impl Into<String>) -> Self {
1252 Self::new(ErrorCode::Conflict, msg)
1253 }
1254
1255 pub fn already_exists(msg: impl Into<String>) -> Self {
1257 Self::new(ErrorCode::ResourceAlreadyExists, msg)
1258 }
1259
1260 pub fn unprocessable(msg: impl Into<String>) -> Self {
1262 Self::new(ErrorCode::UnprocessableEntity, msg)
1263 }
1264
1265 #[must_use]
1267 pub fn rate_limited(retry_after_seconds: u64) -> Self {
1268 Self::new(
1269 ErrorCode::RateLimited,
1270 format!("Rate limited, retry after {retry_after_seconds}s"),
1271 )
1272 }
1273
1274 #[must_use]
1289 pub fn with_rate_limit(mut self, info: crate::ratelimit::RateLimitInfo) -> Self {
1290 self.rate_limit = Some(info);
1291 self
1292 }
1293
1294 #[must_use]
1312 pub fn rate_limited_with(info: crate::ratelimit::RateLimitInfo) -> Self {
1313 let detail = match info.retry_after {
1314 Some(secs) => format!("Rate limited, retry after {secs}s"),
1315 None => "Rate limited".to_string(),
1316 };
1317 Self::new(ErrorCode::RateLimited, detail).with_rate_limit(info)
1318 }
1319
1320 pub fn internal(msg: impl Into<String>) -> Self {
1322 Self::new(ErrorCode::InternalServerError, msg)
1323 }
1324
1325 pub fn unavailable(msg: impl Into<String>) -> Self {
1327 Self::new(ErrorCode::ServiceUnavailable, msg)
1328 }
1329
1330 #[must_use]
1346 pub fn builder() -> ApiErrorBuilder<(), ()> {
1347 ApiErrorBuilder {
1348 code: (),
1349 detail: (),
1350 #[cfg(feature = "uuid")]
1351 request_id: None,
1352 errors: Vec::new(),
1353 causes: Vec::new(),
1354 }
1355 }
1356
1357 #[cfg(feature = "uuid")]
1358 fn with_request_id_opt(mut self, id: Option<uuid::Uuid>) -> Self {
1359 self.request_id = id;
1360 self
1361 }
1362
1363 #[cfg(not(feature = "uuid"))]
1364 fn with_request_id_opt(self, _id: Option<()>) -> Self {
1365 self
1366 }
1367}
1368
1369#[cfg(any(feature = "std", feature = "alloc"))]
1383pub struct ApiErrorBuilder<C, D> {
1384 code: C,
1385 detail: D,
1386 #[cfg(feature = "uuid")]
1387 request_id: Option<uuid::Uuid>,
1388 errors: Vec<ValidationError>,
1389 causes: Vec<ApiError>,
1390}
1391
1392#[cfg(any(feature = "std", feature = "alloc"))]
1393impl<D> ApiErrorBuilder<(), D> {
1394 pub fn code(self, code: ErrorCode) -> ApiErrorBuilder<ErrorCode, D> {
1396 ApiErrorBuilder {
1397 code,
1398 detail: self.detail,
1399 #[cfg(feature = "uuid")]
1400 request_id: self.request_id,
1401 errors: self.errors,
1402 causes: self.causes,
1403 }
1404 }
1405}
1406
1407#[cfg(any(feature = "std", feature = "alloc"))]
1408impl<C> ApiErrorBuilder<C, ()> {
1409 pub fn detail(self, detail: impl Into<String>) -> ApiErrorBuilder<C, String> {
1411 ApiErrorBuilder {
1412 code: self.code,
1413 detail: detail.into(),
1414 #[cfg(feature = "uuid")]
1415 request_id: self.request_id,
1416 errors: self.errors,
1417 causes: self.causes,
1418 }
1419 }
1420}
1421
1422#[cfg(any(feature = "std", feature = "alloc"))]
1423impl<C, D> ApiErrorBuilder<C, D> {
1424 #[cfg(feature = "uuid")]
1426 #[must_use]
1427 pub fn request_id(mut self, id: uuid::Uuid) -> Self {
1428 self.request_id = Some(id);
1429 self
1430 }
1431
1432 #[must_use]
1434 pub fn errors(mut self, errors: Vec<ValidationError>) -> Self {
1435 self.errors = errors;
1436 self
1437 }
1438
1439 #[must_use]
1441 pub fn causes(mut self, causes: Vec<ApiError>) -> Self {
1442 self.causes = causes;
1443 self
1444 }
1445}
1446
1447#[cfg(any(feature = "std", feature = "alloc"))]
1448impl ApiErrorBuilder<ErrorCode, String> {
1449 #[must_use]
1453 pub fn build(self) -> ApiError {
1454 #[cfg(feature = "uuid")]
1455 let built = ApiError::new(self.code, self.detail).with_request_id_opt(self.request_id);
1456 #[cfg(not(feature = "uuid"))]
1457 let built = ApiError::new(self.code, self.detail).with_request_id_opt(None::<()>);
1458 built.with_errors(self.errors).with_causes(self.causes)
1459 }
1460}
1461
1462#[cfg(any(feature = "std", feature = "alloc"))]
1463impl fmt::Display for ApiError {
1464 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1465 write!(f, "[{}] {}", self.code, self.detail)
1466 }
1467}
1468
1469#[cfg(any(feature = "std", feature = "alloc"))]
1470impl core::error::Error for ApiError {
1471 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
1472 self.source
1473 .as_deref()
1474 .map(|s| s as &(dyn core::error::Error + 'static))
1475 }
1476}
1477
1478#[cfg(all(
1485 feature = "proptest",
1486 feature = "uuid",
1487 any(feature = "std", feature = "alloc")
1488))]
1489impl proptest::arbitrary::Arbitrary for ApiError {
1490 type Parameters = ();
1491 type Strategy = proptest::strategy::BoxedStrategy<Self>;
1492
1493 fn arbitrary_with((): ()) -> Self::Strategy {
1494 use proptest::prelude::*;
1495 (
1496 any::<ErrorCode>(),
1497 any::<String>(),
1498 any::<u16>(),
1499 any::<String>(),
1500 proptest::option::of(any::<u128>().prop_map(uuid::Uuid::from_u128)),
1501 any::<Vec<ValidationError>>(),
1502 )
1503 .prop_map(|(code, title, status, detail, request_id, errors)| Self {
1504 code,
1505 title,
1506 status,
1507 detail,
1508 #[cfg(feature = "uuid")]
1509 request_id,
1510 errors,
1511 rate_limit: None,
1512 source: None,
1513 causes: Vec::new(),
1514 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
1515 extensions: BTreeMap::new(),
1516 })
1517 .boxed()
1518 }
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use super::*;
1524
1525 static MODE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1529
1530 struct ModeGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
1533
1534 impl Drop for ModeGuard {
1535 fn drop(&mut self) {
1536 reset_error_type_mode();
1537 }
1538 }
1539
1540 fn lock_and_reset_mode() -> ModeGuard {
1543 let guard = MODE_LOCK
1544 .lock()
1545 .unwrap_or_else(std::sync::PoisonError::into_inner);
1546 reset_error_type_mode();
1547 ModeGuard(guard)
1548 }
1549
1550 #[test]
1555 fn error_code_try_from_u16_non_error_returns_err() {
1556 for code in [100_u16, 200, 204, 301, 302, 304] {
1557 assert!(
1558 ErrorCode::try_from(code).is_err(),
1559 "expected Err for status {code}"
1560 );
1561 }
1562 }
1563
1564 #[test]
1565 fn error_code_try_from_u16_unmapped_4xx_returns_err() {
1566 assert!(ErrorCode::try_from(418_u16).is_err());
1568 }
1569
1570 #[test]
1571 fn error_code_try_from_u16_roundtrip() {
1572 let canonical_variants = [
1577 ErrorCode::BadRequest,
1578 ErrorCode::Unauthorized,
1579 ErrorCode::Forbidden,
1580 ErrorCode::ResourceNotFound,
1581 ErrorCode::MethodNotAllowed,
1582 ErrorCode::NotAcceptable,
1583 ErrorCode::RequestTimeout,
1584 ErrorCode::Conflict,
1585 ErrorCode::Gone,
1586 ErrorCode::PreconditionFailed,
1587 ErrorCode::PayloadTooLarge,
1588 ErrorCode::UnsupportedMediaType,
1589 ErrorCode::UnprocessableEntity,
1590 ErrorCode::PreconditionRequired,
1591 ErrorCode::RateLimited,
1592 ErrorCode::RequestHeaderFieldsTooLarge,
1593 ErrorCode::InternalServerError,
1594 ErrorCode::NotImplemented,
1595 ErrorCode::BadGateway,
1596 ErrorCode::ServiceUnavailable,
1597 ErrorCode::GatewayTimeout,
1598 ];
1599 for variant in &canonical_variants {
1600 let status = variant.status_code();
1601 let roundtripped =
1602 ErrorCode::try_from(status).expect("canonical variant should round-trip");
1603 assert_eq!(
1604 roundtripped, *variant,
1605 "roundtrip failed for {variant:?} (status {status})"
1606 );
1607 }
1608 }
1609
1610 #[cfg(feature = "http")]
1611 #[test]
1612 fn error_code_try_from_status_code_non_error_returns_err() {
1613 use http::StatusCode;
1614 assert!(ErrorCode::try_from(StatusCode::OK).is_err());
1615 assert!(ErrorCode::try_from(StatusCode::MOVED_PERMANENTLY).is_err());
1616 }
1617
1618 #[cfg(feature = "http")]
1619 #[test]
1620 fn error_code_try_from_status_code_roundtrip() {
1621 use http::StatusCode;
1622 let pairs = [
1623 (StatusCode::NOT_FOUND, ErrorCode::ResourceNotFound),
1624 (
1625 StatusCode::INTERNAL_SERVER_ERROR,
1626 ErrorCode::InternalServerError,
1627 ),
1628 (StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited),
1629 (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
1630 ];
1631 for (sc, expected) in &pairs {
1632 assert_eq!(
1633 ErrorCode::try_from(*sc),
1634 Ok(expected.clone()),
1635 "failed for {sc}"
1636 );
1637 }
1638 }
1639
1640 #[test]
1641 fn status_codes() {
1642 assert_eq!(ApiError::bad_request("x").status_code(), 400);
1643 assert_eq!(ApiError::unauthorized("x").status_code(), 401);
1644 assert_eq!(ApiError::invalid_credentials().status_code(), 401);
1645 assert_eq!(ApiError::token_expired().status_code(), 401);
1646 assert_eq!(ApiError::forbidden("x").status_code(), 403);
1647 assert_eq!(ApiError::not_found("x").status_code(), 404);
1648 assert_eq!(ApiError::conflict("x").status_code(), 409);
1649 assert_eq!(ApiError::already_exists("x").status_code(), 409);
1650 assert_eq!(ApiError::unprocessable("x").status_code(), 422);
1651 assert_eq!(ApiError::rate_limited(30).status_code(), 429);
1652 assert_eq!(ApiError::internal("x").status_code(), 500);
1653 assert_eq!(ApiError::unavailable("x").status_code(), 503);
1654 }
1655
1656 #[test]
1657 fn status_in_valid_http_range() {
1658 for err in [
1660 ApiError::bad_request("x"),
1661 ApiError::unauthorized("x"),
1662 ApiError::forbidden("x"),
1663 ApiError::not_found("x"),
1664 ApiError::conflict("x"),
1665 ApiError::unprocessable("x"),
1666 ApiError::rate_limited(30),
1667 ApiError::internal("x"),
1668 ApiError::unavailable("x"),
1669 ] {
1670 assert!(
1671 (100..=599).contains(&err.status),
1672 "status {} out of RFC 9457 §3.1.3 range",
1673 err.status
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn error_code_urn() {
1680 let _g = lock_and_reset_mode();
1681 assert_eq!(
1682 ErrorCode::ResourceNotFound.urn(),
1683 "urn:api-bones:error:resource-not-found"
1684 );
1685 assert_eq!(
1686 ErrorCode::ValidationFailed.urn(),
1687 "urn:api-bones:error:validation-failed"
1688 );
1689 assert_eq!(
1690 ErrorCode::InternalServerError.urn(),
1691 "urn:api-bones:error:internal-server-error"
1692 );
1693 }
1694
1695 #[test]
1696 fn error_code_from_type_uri_roundtrip() {
1697 let _g = lock_and_reset_mode();
1698 let codes = [
1699 ErrorCode::BadRequest,
1700 ErrorCode::ValidationFailed,
1701 ErrorCode::Unauthorized,
1702 ErrorCode::ResourceNotFound,
1703 ErrorCode::InternalServerError,
1704 ErrorCode::ServiceUnavailable,
1705 ];
1706 for code in &codes {
1707 let urn = code.urn();
1708 assert_eq!(ErrorCode::from_type_uri(&urn).as_ref(), Some(code));
1709 }
1710 }
1711
1712 #[test]
1713 fn error_code_from_type_uri_unknown() {
1714 let _g = lock_and_reset_mode();
1715 assert!(ErrorCode::from_type_uri("urn:api-bones:error:unknown-thing").is_none());
1716 assert!(ErrorCode::from_type_uri("RESOURCE_NOT_FOUND").is_none());
1717 }
1718
1719 #[test]
1720 fn display_format() {
1721 let _g = lock_and_reset_mode();
1722 let e = ApiError::not_found("booking 123 not found");
1723 assert_eq!(
1724 e.to_string(),
1725 "[urn:api-bones:error:resource-not-found] booking 123 not found"
1726 );
1727 }
1728
1729 #[test]
1730 fn title_populated() {
1731 let e = ApiError::not_found("x");
1732 assert_eq!(e.title, "Resource Not Found");
1733 }
1734
1735 #[cfg(feature = "uuid")]
1736 #[test]
1737 fn with_request_id() {
1738 let id = uuid::Uuid::new_v4();
1739 let e = ApiError::internal("oops").with_request_id(id);
1740 assert_eq!(e.request_id, Some(id));
1741 }
1742
1743 #[test]
1744 fn with_errors() {
1745 let e = ApiError::validation_failed("invalid input").with_errors(vec![ValidationError {
1746 field: "/email".to_owned(),
1747 message: "invalid format".to_owned(),
1748 rule: Some("format".to_owned()),
1749 }]);
1750 assert!(!e.errors.is_empty());
1751 assert_eq!(e.errors[0].field, "/email");
1752 }
1753
1754 #[cfg(feature = "serde")]
1755 #[test]
1756 fn wire_format() {
1757 let _g = lock_and_reset_mode();
1758 let e = ApiError::not_found("booking 123 not found");
1759 let json = serde_json::to_value(&e).unwrap();
1760 assert!(json.get("error").is_none());
1762 assert_eq!(json["type"], "urn:api-bones:error:resource-not-found");
1764 assert_eq!(json["title"], "Resource Not Found");
1765 assert_eq!(json["status"], 404);
1766 assert_eq!(json["detail"], "booking 123 not found");
1767 assert!(json.get("instance").is_none());
1769 assert!(json.get("errors").is_none());
1770 }
1771
1772 #[cfg(all(feature = "serde", feature = "uuid"))]
1773 #[test]
1774 fn wire_format_instance_is_urn_uuid() {
1775 let _g = lock_and_reset_mode();
1776 let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1778 let e = ApiError::internal("oops").with_request_id(id);
1779 let json = serde_json::to_value(&e).unwrap();
1780 assert_eq!(
1781 json["instance"],
1782 "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
1783 );
1784 assert!(json.get("request_id").is_none());
1786 }
1787
1788 #[cfg(feature = "serde")]
1789 #[test]
1790 fn wire_format_with_errors() {
1791 let _g = lock_and_reset_mode();
1792 let e = ApiError::validation_failed("bad input").with_errors(vec![ValidationError {
1793 field: "/name".to_owned(),
1794 message: "required".to_owned(),
1795 rule: None,
1796 }]);
1797 let json = serde_json::to_value(&e).unwrap();
1798 assert_eq!(json["type"], "urn:api-bones:error:validation-failed");
1799 assert_eq!(json["status"], 400);
1800 assert!(json["errors"].is_array());
1801 assert_eq!(json["errors"][0]["field"], "/name");
1802 }
1803
1804 #[cfg(feature = "serde")]
1805 #[test]
1806 fn snapshot_not_found() {
1807 let _g = lock_and_reset_mode();
1808 let e = ApiError::not_found("booking 123 not found");
1809 let json = serde_json::to_value(&e).unwrap();
1810 let expected = serde_json::json!({
1811 "type": "urn:api-bones:error:resource-not-found",
1812 "title": "Resource Not Found",
1813 "status": 404,
1814 "detail": "booking 123 not found"
1815 });
1816 assert_eq!(json, expected);
1817 }
1818
1819 #[cfg(feature = "serde")]
1820 #[test]
1821 fn snapshot_validation_failed_with_errors() {
1822 let _g = lock_and_reset_mode();
1823 let e = ApiError::validation_failed("invalid input").with_errors(vec![
1824 ValidationError {
1825 field: "/email".to_owned(),
1826 message: "invalid format".to_owned(),
1827 rule: Some("format".to_owned()),
1828 },
1829 ValidationError {
1830 field: "/name".to_owned(),
1831 message: "required".to_owned(),
1832 rule: None,
1833 },
1834 ]);
1835 let json = serde_json::to_value(&e).unwrap();
1836 let expected = serde_json::json!({
1837 "type": "urn:api-bones:error:validation-failed",
1838 "title": "Validation Failed",
1839 "status": 400,
1840 "detail": "invalid input",
1841 "errors": [
1842 {"field": "/email", "message": "invalid format", "rule": "format"},
1843 {"field": "/name", "message": "required"}
1844 ]
1845 });
1846 assert_eq!(json, expected);
1847 }
1848
1849 #[cfg(feature = "serde")]
1850 #[test]
1851 fn error_code_serde_roundtrip() {
1852 let _g = lock_and_reset_mode();
1853 let code = ErrorCode::ResourceNotFound;
1854 let json = serde_json::to_value(&code).unwrap();
1855 assert_eq!(json, "urn:api-bones:error:resource-not-found");
1856 let back: ErrorCode = serde_json::from_value(json).unwrap();
1857 assert_eq!(back, code);
1858 }
1859
1860 #[test]
1861 fn client_vs_server() {
1862 assert!(ApiError::not_found("x").is_client_error());
1863 assert!(!ApiError::not_found("x").is_server_error());
1864 assert!(ApiError::internal("x").is_server_error());
1865 }
1866
1867 #[test]
1872 fn error_type_mode_render_url() {
1873 let mode = ErrorTypeMode::Url {
1874 base_url: "https://docs.example.com/errors".into(),
1875 };
1876 assert_eq!(
1877 mode.render("resource-not-found"),
1878 "https://docs.example.com/errors/resource-not-found"
1879 );
1880 let mode_slash = ErrorTypeMode::Url {
1882 base_url: "https://docs.example.com/errors/".into(),
1883 };
1884 assert_eq!(
1885 mode_slash.render("bad-request"),
1886 "https://docs.example.com/errors/bad-request"
1887 );
1888 }
1889
1890 #[test]
1898 fn set_error_type_mode_url_and_urn_namespace_fallback() {
1899 let _g = lock_and_reset_mode();
1900 set_error_type_mode(ErrorTypeMode::Url {
1901 base_url: "https://docs.test.com/errors".into(),
1902 });
1903 assert_eq!(
1904 error_type_mode(),
1905 ErrorTypeMode::Url {
1906 base_url: "https://docs.test.com/errors".into()
1907 }
1908 );
1909 assert_eq!(urn_namespace(), "api-bones");
1911 }
1912
1913 #[test]
1914 fn urn_namespace_urn_mode_returns_namespace() {
1915 let _g = lock_and_reset_mode();
1916 assert_eq!(urn_namespace(), "api-bones");
1918 }
1919
1920 #[allow(unsafe_code)]
1925 #[test]
1926 fn error_type_mode_url_from_runtime_env() {
1927 let _g = lock_and_reset_mode();
1928 unsafe {
1930 std::env::set_var(
1931 "SHARED_TYPES_ERROR_TYPE_BASE_URL",
1932 "https://env.example.com/errors",
1933 );
1934 }
1935 let mode = error_type_mode();
1936 assert!(
1937 matches!(mode, ErrorTypeMode::Url { base_url } if base_url == "https://env.example.com/errors")
1938 );
1939 unsafe {
1940 std::env::remove_var("SHARED_TYPES_ERROR_TYPE_BASE_URL");
1941 }
1942 }
1943
1944 #[allow(unsafe_code)]
1945 #[test]
1946 fn error_type_mode_urn_from_runtime_env() {
1947 let _g = lock_and_reset_mode();
1948 unsafe {
1950 std::env::set_var("SHARED_TYPES_URN_NAMESPACE", "testapp");
1951 }
1952 let mode = error_type_mode();
1953 assert!(matches!(mode, ErrorTypeMode::Urn { namespace } if namespace == "testapp"));
1954 unsafe {
1955 std::env::remove_var("SHARED_TYPES_URN_NAMESPACE");
1956 }
1957 }
1958
1959 #[test]
1964 fn from_type_uri_url_mode_paths() {
1965 let _g = lock_and_reset_mode();
1966 set_error_type_mode(ErrorTypeMode::Url {
1967 base_url: "https://docs.test.com/errors".into(),
1968 });
1969 assert_eq!(
1971 ErrorCode::from_type_uri("https://docs.test.com/errors/resource-not-found"),
1972 Some(ErrorCode::ResourceNotFound)
1973 );
1974 assert_eq!(
1976 ErrorCode::from_type_uri("urn:api-bones:error:bad-request"),
1977 Some(ErrorCode::BadRequest)
1978 );
1979 assert!(ErrorCode::from_type_uri("https://docs.test.com/errors/totally-unknown").is_none());
1981 assert!(ErrorCode::from_type_uri("not-a-url-or-urn").is_none());
1983 }
1984
1985 #[test]
1991 #[allow(clippy::too_many_lines)]
1992 fn all_error_code_variants_title_slug_status() {
1993 let _g = lock_and_reset_mode();
1994 let cases: &[(ErrorCode, &str, &str, u16)] = &[
1995 (ErrorCode::BadRequest, "Bad Request", "bad-request", 400),
1996 (
1997 ErrorCode::ValidationFailed,
1998 "Validation Failed",
1999 "validation-failed",
2000 400,
2001 ),
2002 (ErrorCode::Unauthorized, "Unauthorized", "unauthorized", 401),
2003 (
2004 ErrorCode::InvalidCredentials,
2005 "Invalid Credentials",
2006 "invalid-credentials",
2007 401,
2008 ),
2009 (
2010 ErrorCode::TokenExpired,
2011 "Token Expired",
2012 "token-expired",
2013 401,
2014 ),
2015 (
2016 ErrorCode::TokenInvalid,
2017 "Token Invalid",
2018 "token-invalid",
2019 401,
2020 ),
2021 (ErrorCode::Forbidden, "Forbidden", "forbidden", 403),
2022 (
2023 ErrorCode::InsufficientPermissions,
2024 "Insufficient Permissions",
2025 "insufficient-permissions",
2026 403,
2027 ),
2028 (
2029 ErrorCode::ResourceNotFound,
2030 "Resource Not Found",
2031 "resource-not-found",
2032 404,
2033 ),
2034 (
2035 ErrorCode::MethodNotAllowed,
2036 "Method Not Allowed",
2037 "method-not-allowed",
2038 405,
2039 ),
2040 (
2041 ErrorCode::NotAcceptable,
2042 "Not Acceptable",
2043 "not-acceptable",
2044 406,
2045 ),
2046 (
2047 ErrorCode::RequestTimeout,
2048 "Request Timeout",
2049 "request-timeout",
2050 408,
2051 ),
2052 (ErrorCode::Conflict, "Conflict", "conflict", 409),
2053 (
2054 ErrorCode::ResourceAlreadyExists,
2055 "Resource Already Exists",
2056 "resource-already-exists",
2057 409,
2058 ),
2059 (ErrorCode::Gone, "Gone", "gone", 410),
2060 (
2061 ErrorCode::PreconditionFailed,
2062 "Precondition Failed",
2063 "precondition-failed",
2064 412,
2065 ),
2066 (
2067 ErrorCode::PayloadTooLarge,
2068 "Payload Too Large",
2069 "payload-too-large",
2070 413,
2071 ),
2072 (
2073 ErrorCode::UnsupportedMediaType,
2074 "Unsupported Media Type",
2075 "unsupported-media-type",
2076 415,
2077 ),
2078 (
2079 ErrorCode::UnprocessableEntity,
2080 "Unprocessable Entity",
2081 "unprocessable-entity",
2082 422,
2083 ),
2084 (
2085 ErrorCode::PreconditionRequired,
2086 "Precondition Required",
2087 "precondition-required",
2088 428,
2089 ),
2090 (ErrorCode::RateLimited, "Rate Limited", "rate-limited", 429),
2091 (
2092 ErrorCode::RequestHeaderFieldsTooLarge,
2093 "Request Header Fields Too Large",
2094 "request-header-fields-too-large",
2095 431,
2096 ),
2097 (
2098 ErrorCode::InternalServerError,
2099 "Internal Server Error",
2100 "internal-server-error",
2101 500,
2102 ),
2103 (
2104 ErrorCode::NotImplemented,
2105 "Not Implemented",
2106 "not-implemented",
2107 501,
2108 ),
2109 (ErrorCode::BadGateway, "Bad Gateway", "bad-gateway", 502),
2110 (
2111 ErrorCode::ServiceUnavailable,
2112 "Service Unavailable",
2113 "service-unavailable",
2114 503,
2115 ),
2116 (
2117 ErrorCode::GatewayTimeout,
2118 "Gateway Timeout",
2119 "gateway-timeout",
2120 504,
2121 ),
2122 ];
2123 for (code, title, slug, status) in cases {
2124 assert_eq!(code.title(), *title, "title mismatch for {slug}");
2125 assert_eq!(code.urn_slug(), *slug, "slug mismatch");
2126 assert_eq!(code.status_code(), *status, "status mismatch for {slug}");
2127 let urn = code.urn();
2129 assert_eq!(
2130 ErrorCode::from_type_uri(&urn).as_ref(),
2131 Some(code),
2132 "from_type_uri roundtrip failed for {urn}"
2133 );
2134 }
2135 }
2136
2137 #[test]
2142 fn insufficient_permissions_constructor() {
2143 let e = ApiError::insufficient_permissions("missing admin role");
2144 assert_eq!(e.status_code(), 403);
2145 assert_eq!(e.title, "Insufficient Permissions");
2146 assert!(e.is_client_error());
2147 }
2148
2149 #[cfg(feature = "serde")]
2155 #[test]
2156 fn error_code_deserialize_non_string_is_error() {
2157 let _g = lock_and_reset_mode();
2158 let result: Result<ErrorCode, _> = serde_json::from_value(serde_json::json!(42));
2160 assert!(result.is_err());
2161 }
2162
2163 #[cfg(feature = "serde")]
2164 #[test]
2165 fn error_code_deserialize_unknown_uri_is_error() {
2166 let _g = lock_and_reset_mode();
2167 let result: Result<ErrorCode, _> =
2169 serde_json::from_value(serde_json::json!("urn:api-bones:error:does-not-exist"));
2170 assert!(result.is_err());
2171 }
2172
2173 #[cfg(all(feature = "serde", feature = "uuid"))]
2174 #[test]
2175 fn uuid_urn_option_serialize_none_produces_null() {
2176 use serde_json::Serializer as JsonSerializer;
2180 let mut buf = Vec::new();
2181 let mut s = JsonSerializer::new(&mut buf);
2182 uuid_urn_option::serialize(&None, &mut s).unwrap();
2183 assert_eq!(buf, b"null");
2184 }
2185
2186 #[cfg(all(feature = "serde", feature = "uuid"))]
2187 #[test]
2188 fn uuid_urn_option_deserialize_non_string_is_error() {
2189 let _g = lock_and_reset_mode();
2190 let json = serde_json::json!({
2193 "type": "urn:api-bones:error:bad-request",
2194 "title": "Bad Request",
2195 "status": 400,
2196 "detail": "x",
2197 "instance": 42
2198 });
2199 let result: Result<ApiError, _> = serde_json::from_value(json);
2200 assert!(result.is_err());
2201 }
2202
2203 #[cfg(all(feature = "serde", feature = "uuid"))]
2204 #[test]
2205 fn uuid_urn_option_deserialize_null_gives_none() {
2206 let _g = lock_and_reset_mode();
2207 let json = serde_json::json!({
2209 "type": "urn:api-bones:error:bad-request",
2210 "title": "Bad Request",
2211 "status": 400,
2212 "detail": "x",
2213 "instance": null
2214 });
2215 let e: ApiError = serde_json::from_value(json).unwrap();
2216 assert!(e.request_id.is_none());
2217 }
2218
2219 #[cfg(all(feature = "serde", feature = "uuid"))]
2220 #[test]
2221 fn uuid_urn_option_deserialize_valid_urn_uuid() {
2222 let _g = lock_and_reset_mode();
2223 let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2225 let json = serde_json::json!({
2226 "type": "urn:api-bones:error:bad-request",
2227 "title": "Bad Request",
2228 "status": 400,
2229 "detail": "x",
2230 "instance": "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
2231 });
2232 let e: ApiError = serde_json::from_value(json).unwrap();
2233 assert_eq!(e.request_id, Some(id));
2234 }
2235
2236 #[cfg(all(feature = "serde", feature = "uuid"))]
2237 #[test]
2238 fn uuid_urn_option_deserialize_bad_prefix_is_error() {
2239 let _g = lock_and_reset_mode();
2240 let json = serde_json::json!({
2242 "type": "urn:api-bones:error:bad-request",
2243 "title": "Bad Request",
2244 "status": 400,
2245 "detail": "x",
2246 "instance": "uuid:550e8400-e29b-41d4-a716-446655440000"
2247 });
2248 let result: Result<ApiError, _> = serde_json::from_value(json);
2249 assert!(result.is_err());
2250 }
2251
2252 #[cfg(feature = "uuid")]
2257 #[test]
2258 fn builder_basic() {
2259 let err = ApiError::builder()
2260 .code(ErrorCode::ResourceNotFound)
2261 .detail("Booking 123 not found")
2262 .build();
2263 assert_eq!(err.status, 404);
2264 assert_eq!(err.title, "Resource Not Found");
2265 assert_eq!(err.detail, "Booking 123 not found");
2266 assert!(err.request_id.is_none());
2267 assert!(err.errors.is_empty());
2268 }
2269
2270 #[test]
2271 fn builder_equivalence_with_new() {
2272 let via_new = ApiError::new(ErrorCode::BadRequest, "bad");
2273 let via_builder = ApiError::builder()
2274 .code(ErrorCode::BadRequest)
2275 .detail("bad")
2276 .build();
2277 assert_eq!(via_new, via_builder);
2278 }
2279
2280 #[cfg(feature = "uuid")]
2281 #[test]
2282 fn builder_chaining_all_optionals() {
2283 let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
2284 let errs = vec![ValidationError {
2285 field: "/email".to_owned(),
2286 message: "invalid".to_owned(),
2287 rule: None,
2288 }];
2289 let err = ApiError::builder()
2290 .code(ErrorCode::ValidationFailed)
2291 .detail("invalid input")
2292 .request_id(id)
2293 .errors(errs.clone())
2294 .build();
2295 assert_eq!(err.request_id, Some(id));
2296 assert_eq!(err.errors, errs);
2297 }
2298
2299 #[test]
2300 fn builder_detail_before_code() {
2301 let err = ApiError::builder()
2303 .detail("forbidden action")
2304 .code(ErrorCode::Forbidden)
2305 .build();
2306 assert_eq!(err.status, 403);
2307 assert_eq!(err.detail, "forbidden action");
2308 }
2309
2310 #[test]
2315 fn api_error_source_none_by_default() {
2316 use std::error::Error;
2317 let err = ApiError::not_found("booking 42");
2318 assert!(err.source().is_none());
2319 }
2320
2321 #[test]
2322 fn api_error_with_source_chain_is_walkable() {
2323 use std::error::Error;
2324
2325 #[derive(Debug)]
2326 struct RootCause;
2327 impl std::fmt::Display for RootCause {
2328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2329 f.write_str("database connection refused")
2330 }
2331 }
2332 impl Error for RootCause {}
2333
2334 let err = ApiError::internal("upstream failure").with_source(RootCause);
2335
2336 let source = err.source().expect("source should be set");
2338 assert_eq!(source.to_string(), "database connection refused");
2339
2340 assert!(source.source().is_none());
2342 }
2343
2344 #[test]
2345 fn api_error_source_chain_two_levels() {
2346 use std::error::Error;
2347
2348 #[derive(Debug)]
2349 struct Mid(std::io::Error);
2350 impl std::fmt::Display for Mid {
2351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2352 write!(f, "mid: {}", self.0)
2353 }
2354 }
2355 impl Error for Mid {
2356 fn source(&self) -> Option<&(dyn Error + 'static)> {
2357 Some(&self.0)
2358 }
2359 }
2360
2361 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
2362 let mid = Mid(io_err);
2363
2364 let err = ApiError::unavailable("service down").with_source(mid);
2365
2366 let hop1 = err.source().expect("first source");
2367 assert!(hop1.to_string().starts_with("mid:"));
2368
2369 let hop2 = hop1.source().expect("second source");
2370 assert_eq!(hop2.to_string(), "timed out");
2371 }
2372
2373 #[test]
2374 fn api_error_partial_eq_ignores_source() {
2375 #[derive(Debug)]
2376 struct Cause;
2377 impl std::fmt::Display for Cause {
2378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2379 f.write_str("cause")
2380 }
2381 }
2382 impl std::error::Error for Cause {}
2383
2384 assert_eq!(Cause.to_string(), "cause");
2386 let a = ApiError::not_found("x");
2387 let b = ApiError::not_found("x").with_source(Cause);
2388 assert_eq!(a, b);
2390 }
2391
2392 #[test]
2393 fn api_error_with_source_is_cloneable() {
2394 use std::error::Error;
2395
2396 #[derive(Debug)]
2397 struct Cause;
2398 impl std::fmt::Display for Cause {
2399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2400 f.write_str("cause")
2401 }
2402 }
2403 impl Error for Cause {}
2404
2405 assert_eq!(Cause.to_string(), "cause");
2407 let a = ApiError::internal("oops").with_source(Cause);
2408 let b = a.clone();
2410 assert!(a.source().is_some());
2412 assert!(b.source().is_some());
2413 }
2414
2415 #[test]
2416 fn validation_error_display_with_rule() {
2417 let ve = ValidationError {
2418 field: "/email".to_owned(),
2419 message: "invalid format".to_owned(),
2420 rule: Some("format".to_owned()),
2421 };
2422 assert_eq!(ve.to_string(), "/email: invalid format (rule: format)");
2423 }
2424
2425 #[test]
2426 fn validation_error_display_without_rule() {
2427 let ve = ValidationError {
2428 field: "/name".to_owned(),
2429 message: "required".to_owned(),
2430 rule: None,
2431 };
2432 assert_eq!(ve.to_string(), "/name: required");
2433 }
2434
2435 #[test]
2436 fn validation_error_is_std_error() {
2437 use std::error::Error;
2438 let ve = ValidationError {
2439 field: "/age".to_owned(),
2440 message: "must be positive".to_owned(),
2441 rule: Some("min".to_owned()),
2442 };
2443 assert!(ve.source().is_none());
2445 let _: &dyn Error = &ve;
2447 }
2448
2449 #[test]
2450 fn api_error_source_downcast() {
2451 use std::error::Error;
2452 use std::sync::Arc;
2453
2454 #[derive(Debug)]
2455 struct Typed(u32);
2456 impl std::fmt::Display for Typed {
2457 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2458 write!(f, "typed({})", self.0)
2459 }
2460 }
2461 impl Error for Typed {}
2462
2463 assert_eq!(Typed(7).to_string(), "typed(7)");
2465 let err = ApiError::internal("oops").with_source(Typed(42));
2466 let source_arc: &Arc<dyn Error + Send + Sync> = err.source.as_ref().expect("source set");
2467 let downcasted = source_arc.downcast_ref::<Typed>();
2468 assert!(downcasted.is_some());
2469 assert_eq!(downcasted.unwrap().0, 42);
2470 }
2471
2472 #[cfg(feature = "schemars")]
2477 #[test]
2478 fn error_code_schema_is_valid() {
2479 let schema = schemars::schema_for!(ErrorCode);
2480 let json = serde_json::to_value(&schema).expect("schema serializable");
2481 assert!(json.is_object(), "schema should be a JSON object");
2482 }
2483
2484 #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
2485 #[test]
2486 fn api_error_schema_is_valid() {
2487 let schema = schemars::schema_for!(ApiError);
2488 let json = serde_json::to_value(&schema).expect("schema serializable");
2489 assert!(json.is_object());
2490 assert!(
2492 json.get("definitions").is_some()
2493 || json.get("$defs").is_some()
2494 || json.get("properties").is_some(),
2495 "schema should contain definitions or properties"
2496 );
2497 }
2498
2499 #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
2500 #[test]
2501 fn validation_error_schema_is_valid() {
2502 let schema = schemars::schema_for!(ValidationError);
2503 let json = serde_json::to_value(&schema).expect("schema serializable");
2504 assert!(json.is_object());
2505 }
2506
2507 #[test]
2512 fn http_error_blanket_from() {
2513 #[derive(Debug)]
2514 struct NotFound(u64);
2515
2516 impl HttpError for NotFound {
2517 fn status_code(&self) -> u16 {
2518 404
2519 }
2520 fn error_code(&self) -> ErrorCode {
2521 ErrorCode::ResourceNotFound
2522 }
2523 fn detail(&self) -> String {
2524 format!("item {} not found", self.0)
2525 }
2526 }
2527
2528 assert_eq!(NotFound(99).status_code(), 404);
2529 let err: ApiError = NotFound(99).into();
2530 assert_eq!(err.status, 404);
2531 assert_eq!(err.code, ErrorCode::ResourceNotFound);
2532 assert_eq!(err.detail, "item 99 not found");
2533 }
2534
2535 #[test]
2540 fn with_causes_roundtrip() {
2541 let cause = ApiError::not_found("upstream missing");
2542 let err = ApiError::internal("pipeline failed").with_causes(vec![cause.clone()]);
2543 assert_eq!(err.causes.len(), 1);
2544 assert_eq!(err.causes[0].detail, cause.detail);
2545 }
2546
2547 #[cfg(feature = "serde")]
2548 #[test]
2549 fn causes_serialized_as_extension() {
2550 let _g = lock_and_reset_mode();
2551 let cause = ApiError::not_found("db row missing");
2552 let err = ApiError::internal("handler failed").with_causes(vec![cause]);
2553 let json = serde_json::to_value(&err).unwrap();
2554 let causes = json["causes"].as_array().expect("causes must be array");
2555 assert_eq!(causes.len(), 1);
2556 assert_eq!(causes[0]["status"], 404);
2557 assert_eq!(causes[0]["detail"], "db row missing");
2558 }
2559
2560 #[cfg(feature = "serde")]
2561 #[test]
2562 fn causes_omitted_when_empty() {
2563 let _g = lock_and_reset_mode();
2564 let err = ApiError::internal("oops");
2565 let json = serde_json::to_value(&err).unwrap();
2566 assert!(json.get("causes").is_none());
2567 }
2568
2569 #[cfg(feature = "serde")]
2570 #[test]
2571 fn causes_propagated_through_problem_json() {
2572 use crate::error::ProblemJson;
2573 let _g = lock_and_reset_mode();
2574 let cause = ApiError::not_found("missing row");
2575 let err = ApiError::internal("failed").with_causes(vec![cause]);
2576 let p = ProblemJson::from(err);
2577 assert!(p.extensions.contains_key("causes"));
2578 let causes = p.extensions["causes"].as_array().unwrap();
2579 assert_eq!(causes.len(), 1);
2580 assert_eq!(causes[0]["status"], 404);
2581 }
2582
2583 #[test]
2584 fn builder_with_causes() {
2585 let cause = ApiError::bad_request("bad input");
2586 let err = ApiError::builder()
2587 .code(ErrorCode::UnprocessableEntity)
2588 .detail("entity failed")
2589 .causes(vec![cause.clone()])
2590 .build();
2591 assert_eq!(err.causes.len(), 1);
2592 assert_eq!(err.causes[0].detail, cause.detail);
2593 }
2594
2595 #[cfg(feature = "serde")]
2600 #[test]
2601 fn with_extension_roundtrip() {
2602 let _g = lock_and_reset_mode();
2603 let err = ApiError::internal("boom").with_extension("trace_id", "abc-123");
2604 assert_eq!(err.extensions["trace_id"], "abc-123");
2605 }
2606
2607 #[cfg(feature = "serde")]
2608 #[test]
2609 fn extension_flattened_in_wire_format() {
2610 let _g = lock_and_reset_mode();
2611 let err = ApiError::not_found("gone").with_extension("tenant", "acme");
2612 let json = serde_json::to_value(&err).unwrap();
2613 assert_eq!(json["tenant"], "acme");
2615 assert_eq!(json["status"], 404);
2617 }
2618
2619 #[cfg(feature = "serde")]
2620 #[test]
2621 fn extension_roundtrip_ser_de() {
2622 let _g = lock_and_reset_mode();
2623 let err = ApiError::bad_request("bad").with_extension("request_num", 42_u64);
2624 let json = serde_json::to_value(&err).unwrap();
2625 let back: ApiError = serde_json::from_value(json).unwrap();
2626 assert_eq!(back.extensions["request_num"], 42_u64);
2627 }
2628
2629 #[cfg(feature = "serde")]
2630 #[test]
2631 fn extension_propagated_through_problem_json() {
2632 use crate::error::ProblemJson;
2633 let _g = lock_and_reset_mode();
2634 let err = ApiError::forbidden("denied").with_extension("policy", "read-only");
2635 let p = ProblemJson::from(err);
2636 assert_eq!(p.extensions["policy"], "read-only");
2637 }
2638}
2639
2640#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
2695#[derive(Debug, Clone, PartialEq)]
2696#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2697#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2698#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2699#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
2700#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
2701pub struct ProblemJson {
2702 #[cfg_attr(feature = "serde", serde(rename = "type"))]
2704 pub r#type: String,
2705 pub title: String,
2707 pub status: u16,
2709 pub detail: String,
2711 #[cfg_attr(
2713 feature = "serde",
2714 serde(default, skip_serializing_if = "Option::is_none")
2715 )]
2716 pub instance: Option<String>,
2717 #[cfg_attr(feature = "serde", serde(flatten))]
2723 #[cfg_attr(feature = "schemars", schemars(skip))]
2724 #[cfg_attr(feature = "arbitrary", arbitrary(default))]
2725 #[cfg_attr(
2726 feature = "proptest",
2727 proptest(strategy = "proptest::strategy::Just(BTreeMap::new())")
2728 )]
2729 pub extensions: BTreeMap<String, serde_json::Value>,
2730}
2731
2732#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
2733impl ProblemJson {
2734 #[must_use]
2751 pub fn new(
2752 r#type: impl Into<String>,
2753 title: impl Into<String>,
2754 status: u16,
2755 detail: impl Into<String>,
2756 ) -> Self {
2757 Self {
2758 r#type: r#type.into(),
2759 title: title.into(),
2760 status,
2761 detail: detail.into(),
2762 instance: None,
2763 extensions: BTreeMap::new(),
2764 }
2765 }
2766
2767 #[must_use]
2779 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
2780 self.instance = Some(instance.into());
2781 self
2782 }
2783
2784 pub fn extend(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
2796 self.extensions.insert(key.into(), value.into());
2797 }
2798}
2799
2800#[cfg(all(feature = "std", feature = "serde"))]
2801impl From<ApiError> for ProblemJson {
2802 fn from(err: ApiError) -> Self {
2820 let mut p = Self::new(err.code.urn(), err.title, err.status, err.detail);
2821
2822 #[cfg(feature = "uuid")]
2823 if let Some(id) = err.request_id {
2824 p.instance = Some(format!("urn:uuid:{id}"));
2825 }
2826
2827 if !err.errors.is_empty() {
2828 let errs =
2829 serde_json::to_value(&err.errors).unwrap_or(serde_json::Value::Array(vec![]));
2830 p.extensions.insert("errors".into(), errs);
2831 }
2832
2833 if let Some(info) = err.rate_limit
2834 && let Ok(v) = serde_json::to_value(&info)
2835 {
2836 p.extensions.insert("rate_limit".into(), v);
2837 }
2838
2839 if !err.causes.is_empty() {
2840 let causes: Vec<serde_json::Value> = err
2841 .causes
2842 .into_iter()
2843 .map(|c| {
2844 let cp = Self::from(c);
2845 serde_json::to_value(cp).unwrap_or(serde_json::Value::Null)
2846 })
2847 .collect();
2848 p.extensions
2849 .insert("causes".into(), serde_json::Value::Array(causes));
2850 }
2851
2852 for (k, v) in err.extensions {
2855 p.extensions.insert(k, v);
2856 }
2857
2858 p
2859 }
2860}
2861
2862#[cfg(all(feature = "std", feature = "serde", test))]
2863mod problem_json_tests {
2864 use super::*;
2865
2866 #[test]
2867 fn new_sets_fields_and_empty_extensions() {
2868 let p = ProblemJson::new(
2869 "urn:api-bones:error:bad-request",
2870 "Bad Request",
2871 400,
2872 "missing email",
2873 );
2874 assert_eq!(p.r#type, "urn:api-bones:error:bad-request");
2875 assert_eq!(p.title, "Bad Request");
2876 assert_eq!(p.status, 400);
2877 assert_eq!(p.detail, "missing email");
2878 assert!(p.instance.is_none());
2879 assert!(p.extensions.is_empty());
2880 }
2881
2882 #[test]
2883 fn with_instance_sets_instance() {
2884 let p = ProblemJson::new("urn:t", "T", 400, "d")
2885 .with_instance("urn:uuid:00000000-0000-0000-0000-000000000000");
2886 assert_eq!(
2887 p.instance.as_deref(),
2888 Some("urn:uuid:00000000-0000-0000-0000-000000000000")
2889 );
2890 }
2891
2892 #[test]
2893 fn extend_inserts_entry() {
2894 let mut p = ProblemJson::new("urn:t", "T", 400, "d");
2895 p.extend("trace_id", "abc123");
2896 assert_eq!(p.extensions["trace_id"], "abc123");
2897 }
2898
2899 #[test]
2900 fn from_api_error_maps_standard_fields() {
2901 #[cfg(feature = "std")]
2902 let _ = super::super::error_type_mode(); let err = ApiError::new(ErrorCode::Forbidden, "not allowed");
2904 let p = ProblemJson::from(err);
2905 assert_eq!(p.status, 403);
2906 assert_eq!(p.title, "Forbidden");
2907 assert_eq!(p.detail, "not allowed");
2908 }
2909
2910 #[test]
2911 fn from_api_error_maps_rate_limit_to_extension() {
2912 use crate::ratelimit::RateLimitInfo;
2913 let info = RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(30);
2914 let err = ApiError::rate_limited_with(info);
2915 let p = ProblemJson::from(err);
2916 assert!(p.extensions.contains_key("rate_limit"));
2917 let rl = &p.extensions["rate_limit"];
2918 assert_eq!(rl["limit"], 100);
2919 assert_eq!(rl["remaining"], 0);
2920 assert_eq!(rl["reset"], 1_700_000_000_u64);
2921 assert_eq!(rl["retry_after"], 30);
2922 }
2923
2924 #[test]
2925 fn api_error_rate_limit_serializes_inline() {
2926 use crate::ratelimit::RateLimitInfo;
2927 let err = ApiError::rate_limited(60)
2928 .with_rate_limit(RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(60));
2929 let json = serde_json::to_value(&err).unwrap();
2930 assert_eq!(json["rate_limit"]["limit"], 100);
2931 assert_eq!(json["rate_limit"]["retry_after"], 60);
2932 }
2933
2934 #[test]
2935 fn api_error_rate_limit_omitted_when_none() {
2936 let err = ApiError::bad_request("x");
2937 let json = serde_json::to_value(&err).unwrap();
2938 assert!(json.get("rate_limit").is_none());
2939 }
2940
2941 #[test]
2942 fn from_api_error_maps_validation_errors_to_extension() {
2943 let err = ApiError::new(ErrorCode::ValidationFailed, "bad input").with_errors(vec![
2944 ValidationError {
2945 field: "/email".into(),
2946 message: "invalid".into(),
2947 rule: None,
2948 },
2949 ]);
2950 let p = ProblemJson::from(err);
2951 assert!(p.extensions.contains_key("errors"));
2952 let errs = p.extensions["errors"].as_array().unwrap();
2953 assert_eq!(errs.len(), 1);
2954 assert_eq!(errs[0]["field"], "/email");
2955 }
2956
2957 #[cfg(feature = "uuid")]
2958 #[test]
2959 fn from_api_error_maps_request_id_to_instance() {
2960 let id = uuid::Uuid::nil();
2961 let err = ApiError::new(ErrorCode::BadRequest, "x").with_request_id(id);
2962 let p = ProblemJson::from(err);
2963 assert_eq!(
2964 p.instance.as_deref(),
2965 Some("urn:uuid:00000000-0000-0000-0000-000000000000")
2966 );
2967 }
2968
2969 #[test]
2970 fn serializes_extensions_flat() {
2971 let mut p = ProblemJson::new("urn:t", "T", 400, "d");
2972 p.extend("trace_id", "xyz");
2973 let json: serde_json::Value =
2974 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
2975 assert_eq!(json["trace_id"], "xyz");
2977 assert!(json.get("extensions").is_none());
2978 }
2979
2980 #[test]
2981 fn instance_omitted_when_none() {
2982 let p = ProblemJson::new("urn:t", "T", 400, "d");
2983 let json: serde_json::Value =
2984 serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
2985 assert!(json.get("instance").is_none());
2986 }
2987}
2988
2989#[cfg(feature = "axum")]
2994mod axum_impl {
2995 use super::ApiError;
2996 use axum::response::{IntoResponse, Response};
2997 use http::{HeaderValue, StatusCode};
2998
2999 impl IntoResponse for ApiError {
3000 fn into_response(self) -> Response {
3001 let status =
3002 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
3003 let body = serde_json::to_string(&self).expect("ApiError serialization is infallible");
3006
3007 let mut response = (status, body).into_response();
3008 response.headers_mut().insert(
3009 http::header::CONTENT_TYPE,
3010 HeaderValue::from_static("application/problem+json"),
3011 );
3012 response
3013 }
3014 }
3015
3016 #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
3017 impl IntoResponse for super::ProblemJson {
3018 fn into_response(self) -> Response {
3019 let status =
3020 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
3021 let body =
3022 serde_json::to_string(&self).expect("ProblemJson serialization is infallible");
3023 let mut response = (status, body).into_response();
3024 response.headers_mut().insert(
3025 http::header::CONTENT_TYPE,
3026 HeaderValue::from_static("application/problem+json"),
3027 );
3028 response
3029 }
3030 }
3031}
3032
3033#[cfg(all(test, feature = "axum"))]
3034mod axum_tests {
3035 use super::*;
3036 use axum::response::IntoResponse;
3037 use http::StatusCode;
3038
3039 #[tokio::test]
3040 async fn into_response_status_and_content_type() {
3041 reset_error_type_mode();
3042 let err = ApiError::not_found("thing 42 not found");
3043 let response = err.into_response();
3044 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3045 assert_eq!(
3046 response.headers().get("content-type").unwrap(),
3047 "application/problem+json"
3048 );
3049 }
3050
3051 #[tokio::test]
3052 async fn into_response_body() {
3053 reset_error_type_mode();
3054 let err = ApiError::unauthorized("bad token");
3055 let response = err.into_response();
3056 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3057 .await
3058 .unwrap();
3059 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3060 assert_eq!(json["type"], "urn:api-bones:error:unauthorized");
3061 assert_eq!(json["status"], 401);
3062 assert_eq!(json["detail"], "bad token");
3063 }
3064
3065 #[cfg(feature = "utoipa")]
3066 #[test]
3067 fn error_code_schema_is_string_type() {
3068 use utoipa::PartialSchema as _;
3069 use utoipa::openapi::schema::Schema;
3070
3071 let schema_ref = ErrorCode::schema();
3072 let schema = match schema_ref {
3073 utoipa::openapi::RefOr::T(s) => s,
3074 utoipa::openapi::RefOr::Ref(_) => panic!("expected inline schema"),
3075 };
3076 assert!(
3077 matches!(schema, Schema::Object(_)),
3078 "ErrorCode schema should be an object (string type)"
3079 );
3080 }
3081
3082 #[cfg(feature = "utoipa")]
3083 #[test]
3084 fn error_code_schema_name() {
3085 use utoipa::ToSchema as _;
3086 assert_eq!(ErrorCode::name(), "ErrorCode");
3087 }
3088
3089 #[cfg(feature = "serde")]
3090 #[tokio::test]
3091 async fn problem_json_into_response_status_and_content_type() {
3092 use super::ProblemJson;
3093 let p = ProblemJson::new("urn:api-bones:error:not-found", "Not Found", 404, "gone");
3094 let response = p.into_response();
3095 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3096 assert_eq!(
3097 response.headers().get("content-type").unwrap(),
3098 "application/problem+json"
3099 );
3100 }
3101
3102 #[cfg(feature = "serde")]
3103 #[tokio::test]
3104 async fn problem_json_into_response_body_with_extension() {
3105 use super::ProblemJson;
3106 let mut p = ProblemJson::new(
3107 "urn:api-bones:error:bad-request",
3108 "Bad Request",
3109 400,
3110 "missing field",
3111 );
3112 p.extend("trace_id", "abc123");
3113 let response = p.into_response();
3114 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3115 .await
3116 .unwrap();
3117 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3118 assert_eq!(json["type"], "urn:api-bones:error:bad-request");
3119 assert_eq!(json["status"], 400);
3120 assert_eq!(json["trace_id"], "abc123");
3121 assert!(json.get("extensions").is_none());
3122 }
3123
3124 #[cfg(feature = "serde")]
3125 #[tokio::test]
3126 async fn problem_json_instance_omitted_when_none() {
3127 use super::ProblemJson;
3128 let p = ProblemJson::new("urn:t", "T", 500, "d");
3129 let response = p.into_response();
3130 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3131 .await
3132 .unwrap();
3133 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3134 assert!(json.get("instance").is_none());
3135 }
3136
3137 #[test]
3142 fn rate_limited_with_no_retry_after() {
3143 use crate::ratelimit::RateLimitInfo;
3144 let info = RateLimitInfo::new(100, 5, 1_700_000_000);
3145 let err = ApiError::rate_limited_with(info);
3146 assert_eq!(err.status, 429);
3147 assert_eq!(err.detail, "Rate limited");
3148 assert!(err.rate_limit.is_some());
3149 }
3150}