#[cfg(all(not(feature = "std"), feature = "alloc", feature = "serde"))]
use alloc::collections::BTreeMap;
#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::{borrow::ToOwned, format, string::String, string::ToString, sync::Arc, vec::Vec};
use core::fmt;
#[cfg(all(feature = "std", not(feature = "serde")))]
use std::sync::Arc;
#[cfg(all(feature = "std", feature = "serde"))]
use std::{collections::BTreeMap, sync::Arc};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub enum ErrorCode {
BadRequest,
ValidationFailed,
Unauthorized,
InvalidCredentials,
TokenExpired,
TokenInvalid,
Forbidden,
InsufficientPermissions,
OrgOutsideSubtree,
AncestorRequired,
CrossSubtreeAccess,
ResourceNotFound,
MethodNotAllowed,
NotAcceptable,
RequestTimeout,
Conflict,
ResourceAlreadyExists,
Gone,
PreconditionFailed,
PayloadTooLarge,
UnsupportedMediaType,
UnprocessableEntity,
PreconditionRequired,
RateLimited,
RequestHeaderFieldsTooLarge,
InternalServerError,
NotImplemented,
BadGateway,
ServiceUnavailable,
GatewayTimeout,
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub enum ErrorTypeMode {
Url {
base_url: String,
},
Urn {
namespace: String,
},
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl ErrorTypeMode {
#[must_use]
pub fn render(&self, slug: &str) -> String {
match self {
Self::Url { base_url } => format!("{}/{slug}", base_url.trim_end_matches('/')),
Self::Urn { namespace } => format!("urn:{namespace}:error:{slug}"),
}
}
}
#[cfg(feature = "std")]
static ERROR_TYPE_MODE: std::sync::RwLock<Option<ErrorTypeMode>> = std::sync::RwLock::new(None);
#[cfg(feature = "std")]
fn resolve_error_type_mode() -> ErrorTypeMode {
#[cfg(not(coverage))]
if let Some(url) = option_env!("SHARED_TYPES_ERROR_TYPE_BASE_URL")
&& !url.is_empty()
{
return ErrorTypeMode::Url {
base_url: url.to_owned(),
};
}
if let Ok(url) = std::env::var("SHARED_TYPES_ERROR_TYPE_BASE_URL")
&& !url.is_empty()
{
return ErrorTypeMode::Url { base_url: url };
}
#[cfg(not(coverage))]
if let Some(ns) = option_env!("SHARED_TYPES_URN_NAMESPACE")
&& !ns.is_empty()
{
return ErrorTypeMode::Urn {
namespace: ns.to_owned(),
};
}
if let Ok(ns) = std::env::var("SHARED_TYPES_URN_NAMESPACE")
&& !ns.is_empty()
{
return ErrorTypeMode::Urn { namespace: ns };
}
ErrorTypeMode::Urn {
namespace: "api-bones".to_owned(),
}
}
#[cfg(feature = "std")]
#[must_use]
pub fn error_type_mode() -> ErrorTypeMode {
{
let guard = ERROR_TYPE_MODE
.read()
.expect("error type mode lock poisoned");
if let Some(mode) = guard.as_ref() {
return mode.clone();
}
}
let mut guard = ERROR_TYPE_MODE
.write()
.expect("error type mode lock poisoned");
if let Some(mode) = guard.as_ref() {
return mode.clone();
}
let mode = resolve_error_type_mode();
*guard = Some(mode.clone());
mode
}
#[cfg(feature = "std")]
pub fn set_error_type_mode(mode: ErrorTypeMode) {
let mut guard = ERROR_TYPE_MODE
.write()
.expect("error type mode lock poisoned");
*guard = Some(mode);
}
#[cfg(all(test, feature = "std"))]
pub(crate) fn reset_error_type_mode() {
let mut guard = ERROR_TYPE_MODE
.write()
.expect("error type mode lock poisoned");
*guard = None;
}
#[cfg(feature = "std")]
#[must_use]
pub fn urn_namespace() -> String {
match error_type_mode() {
ErrorTypeMode::Urn { namespace } => namespace,
ErrorTypeMode::Url { .. } => "api-bones".to_owned(),
}
}
impl ErrorCode {
#[must_use]
pub fn status_code(&self) -> u16 {
match self {
Self::BadRequest | Self::ValidationFailed => 400,
Self::Unauthorized
| Self::InvalidCredentials
| Self::TokenExpired
| Self::TokenInvalid => 401,
Self::Forbidden
| Self::InsufficientPermissions
| Self::OrgOutsideSubtree
| Self::AncestorRequired
| Self::CrossSubtreeAccess => 403,
Self::ResourceNotFound => 404,
Self::MethodNotAllowed => 405,
Self::NotAcceptable => 406,
Self::RequestTimeout => 408,
Self::Conflict | Self::ResourceAlreadyExists => 409,
Self::Gone => 410,
Self::PreconditionFailed => 412,
Self::PayloadTooLarge => 413,
Self::UnsupportedMediaType => 415,
Self::UnprocessableEntity => 422,
Self::PreconditionRequired => 428,
Self::RateLimited => 429,
Self::RequestHeaderFieldsTooLarge => 431,
Self::InternalServerError => 500,
Self::NotImplemented => 501,
Self::BadGateway => 502,
Self::ServiceUnavailable => 503,
Self::GatewayTimeout => 504,
}
}
#[must_use]
pub fn title(&self) -> &'static str {
match self {
Self::BadRequest => "Bad Request",
Self::ValidationFailed => "Validation Failed",
Self::Unauthorized => "Unauthorized",
Self::InvalidCredentials => "Invalid Credentials",
Self::TokenExpired => "Token Expired",
Self::TokenInvalid => "Token Invalid",
Self::Forbidden => "Forbidden",
Self::InsufficientPermissions => "Insufficient Permissions",
Self::OrgOutsideSubtree => "Org Outside Subtree",
Self::AncestorRequired => "Ancestor Required",
Self::CrossSubtreeAccess => "Cross Subtree Access",
Self::ResourceNotFound => "Resource Not Found",
Self::MethodNotAllowed => "Method Not Allowed",
Self::NotAcceptable => "Not Acceptable",
Self::RequestTimeout => "Request Timeout",
Self::Conflict => "Conflict",
Self::ResourceAlreadyExists => "Resource Already Exists",
Self::Gone => "Gone",
Self::PreconditionFailed => "Precondition Failed",
Self::PayloadTooLarge => "Payload Too Large",
Self::UnsupportedMediaType => "Unsupported Media Type",
Self::UnprocessableEntity => "Unprocessable Entity",
Self::PreconditionRequired => "Precondition Required",
Self::RateLimited => "Rate Limited",
Self::RequestHeaderFieldsTooLarge => "Request Header Fields Too Large",
Self::InternalServerError => "Internal Server Error",
Self::NotImplemented => "Not Implemented",
Self::BadGateway => "Bad Gateway",
Self::ServiceUnavailable => "Service Unavailable",
Self::GatewayTimeout => "Gateway Timeout",
}
}
#[must_use]
pub fn urn_slug(&self) -> &'static str {
match self {
Self::BadRequest => "bad-request",
Self::ValidationFailed => "validation-failed",
Self::Unauthorized => "unauthorized",
Self::InvalidCredentials => "invalid-credentials",
Self::TokenExpired => "token-expired",
Self::TokenInvalid => "token-invalid",
Self::Forbidden => "forbidden",
Self::InsufficientPermissions => "insufficient-permissions",
Self::OrgOutsideSubtree => "org-outside-subtree",
Self::AncestorRequired => "ancestor-required",
Self::CrossSubtreeAccess => "cross-subtree-access",
Self::ResourceNotFound => "resource-not-found",
Self::MethodNotAllowed => "method-not-allowed",
Self::NotAcceptable => "not-acceptable",
Self::RequestTimeout => "request-timeout",
Self::Conflict => "conflict",
Self::ResourceAlreadyExists => "resource-already-exists",
Self::Gone => "gone",
Self::PreconditionFailed => "precondition-failed",
Self::PayloadTooLarge => "payload-too-large",
Self::UnsupportedMediaType => "unsupported-media-type",
Self::UnprocessableEntity => "unprocessable-entity",
Self::PreconditionRequired => "precondition-required",
Self::RateLimited => "rate-limited",
Self::RequestHeaderFieldsTooLarge => "request-header-fields-too-large",
Self::InternalServerError => "internal-server-error",
Self::NotImplemented => "not-implemented",
Self::BadGateway => "bad-gateway",
Self::ServiceUnavailable => "service-unavailable",
Self::GatewayTimeout => "gateway-timeout",
}
}
#[cfg(feature = "std")]
#[must_use]
pub fn urn(&self) -> String {
error_type_mode().render(self.urn_slug())
}
#[cfg(feature = "std")]
#[must_use]
pub fn from_type_uri(s: &str) -> Option<Self> {
let slug = match error_type_mode() {
ErrorTypeMode::Url { base_url } => {
let prefix = format!("{}/", base_url.trim_end_matches('/'));
s.strip_prefix(prefix.as_str()).or_else(|| {
let urn_prefix = format!("urn:{}:error:", urn_namespace());
s.strip_prefix(urn_prefix.as_str())
})?
}
ErrorTypeMode::Urn { namespace } => {
let prefix = format!("urn:{namespace}:error:");
s.strip_prefix(prefix.as_str())?
}
};
Some(match slug {
"bad-request" => Self::BadRequest,
"validation-failed" => Self::ValidationFailed,
"unauthorized" => Self::Unauthorized,
"invalid-credentials" => Self::InvalidCredentials,
"token-expired" => Self::TokenExpired,
"token-invalid" => Self::TokenInvalid,
"forbidden" => Self::Forbidden,
"insufficient-permissions" => Self::InsufficientPermissions,
"org-outside-subtree" => Self::OrgOutsideSubtree,
"ancestor-required" => Self::AncestorRequired,
"cross-subtree-access" => Self::CrossSubtreeAccess,
"resource-not-found" => Self::ResourceNotFound,
"method-not-allowed" => Self::MethodNotAllowed,
"not-acceptable" => Self::NotAcceptable,
"request-timeout" => Self::RequestTimeout,
"conflict" => Self::Conflict,
"resource-already-exists" => Self::ResourceAlreadyExists,
"gone" => Self::Gone,
"precondition-failed" => Self::PreconditionFailed,
"payload-too-large" => Self::PayloadTooLarge,
"unsupported-media-type" => Self::UnsupportedMediaType,
"unprocessable-entity" => Self::UnprocessableEntity,
"precondition-required" => Self::PreconditionRequired,
"rate-limited" => Self::RateLimited,
"request-header-fields-too-large" => Self::RequestHeaderFieldsTooLarge,
"internal-server-error" => Self::InternalServerError,
"not-implemented" => Self::NotImplemented,
"bad-gateway" => Self::BadGateway,
"service-unavailable" => Self::ServiceUnavailable,
"gateway-timeout" => Self::GatewayTimeout,
_ => return None,
})
}
}
#[cfg(feature = "std")]
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.urn())
}
}
#[cfg(not(feature = "std"))]
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "urn:api-bones:error:{}", self.urn_slug())
}
}
#[cfg(all(feature = "serde", feature = "std"))]
impl Serialize for ErrorCode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.urn())
}
}
#[cfg(all(feature = "serde", feature = "std"))]
impl<'de> Deserialize<'de> for ErrorCode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_type_uri(&s)
.ok_or_else(|| serde::de::Error::custom(format!("unknown error type URI: {s}")))
}
}
#[cfg(feature = "utoipa")]
impl utoipa::PartialSchema for ErrorCode {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.examples(["urn:api-bones:error:resource-not-found"])
.build(),
))
}
}
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema for ErrorCode {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("ErrorCode")
}
}
impl TryFrom<u16> for ErrorCode {
type Error = ();
fn try_from(status: u16) -> Result<Self, Self::Error> {
match status {
400 => Ok(Self::BadRequest),
401 => Ok(Self::Unauthorized),
403 => Ok(Self::Forbidden),
404 => Ok(Self::ResourceNotFound),
405 => Ok(Self::MethodNotAllowed),
406 => Ok(Self::NotAcceptable),
408 => Ok(Self::RequestTimeout),
409 => Ok(Self::Conflict),
410 => Ok(Self::Gone),
412 => Ok(Self::PreconditionFailed),
413 => Ok(Self::PayloadTooLarge),
415 => Ok(Self::UnsupportedMediaType),
422 => Ok(Self::UnprocessableEntity),
428 => Ok(Self::PreconditionRequired),
429 => Ok(Self::RateLimited),
431 => Ok(Self::RequestHeaderFieldsTooLarge),
500 => Ok(Self::InternalServerError),
501 => Ok(Self::NotImplemented),
502 => Ok(Self::BadGateway),
503 => Ok(Self::ServiceUnavailable),
504 => Ok(Self::GatewayTimeout),
_ => Err(()),
}
}
}
#[cfg(feature = "http")]
impl TryFrom<http::StatusCode> for ErrorCode {
type Error = ();
fn try_from(status: http::StatusCode) -> Result<Self, Self::Error> {
Self::try_from(status.as_u16())
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct ValidationError {
pub field: String,
pub message: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub rule: Option<String>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.rule {
Some(rule) => write!(f, "{}: {} (rule: {})", self.field, self.message, rule),
None => write!(f, "{}: {}", self.field, self.message),
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl core::error::Error for ValidationError {}
#[cfg(any(feature = "std", feature = "alloc"))]
pub trait HttpError: core::fmt::Debug {
fn status_code(&self) -> u16;
fn error_code(&self) -> ErrorCode;
fn detail(&self) -> String;
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<E: HttpError> From<E> for ApiError {
fn from(e: E) -> Self {
Self::new(e.error_code(), e.detail())
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone)]
#[cfg_attr(
all(feature = "std", feature = "serde"),
derive(Serialize, Deserialize)
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct ApiError {
#[cfg_attr(all(feature = "std", feature = "serde"), serde(rename = "type"))]
pub code: ErrorCode,
pub title: String,
pub status: u16,
pub detail: String,
#[cfg(feature = "uuid")]
#[cfg_attr(
all(feature = "std", feature = "serde"),
serde(
rename = "instance",
default,
skip_serializing_if = "Option::is_none",
with = "uuid_urn_option"
)
)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
pub request_id: Option<uuid::Uuid>,
#[cfg_attr(
all(feature = "std", feature = "serde"),
serde(default, skip_serializing_if = "Vec::is_empty")
)]
pub errors: Vec<ValidationError>,
#[cfg_attr(
all(feature = "std", feature = "serde"),
serde(default, skip_serializing_if = "Option::is_none")
)]
#[cfg_attr(feature = "arbitrary", arbitrary(default))]
pub rate_limit: Option<crate::ratelimit::RateLimitInfo>,
#[cfg(any(feature = "std", feature = "alloc"))]
#[cfg_attr(all(feature = "std", feature = "serde"), serde(skip))]
#[cfg_attr(feature = "utoipa", schema(value_type = (), ignore))]
#[cfg_attr(feature = "schemars", schemars(skip))]
#[cfg_attr(feature = "arbitrary", arbitrary(default))]
pub source: Option<Arc<dyn core::error::Error + Send + Sync + 'static>>,
#[cfg_attr(
all(feature = "std", feature = "serde"),
serde(default, skip_serializing_if = "Vec::is_empty")
)]
#[cfg_attr(feature = "arbitrary", arbitrary(default))]
#[cfg_attr(feature = "utoipa", schema(value_type = Vec<Object>))]
pub causes: Vec<Self>,
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
#[cfg_attr(all(feature = "std", feature = "serde"), serde(flatten))]
#[cfg_attr(feature = "arbitrary", arbitrary(default))]
pub extensions: BTreeMap<String, serde_json::Value>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl PartialEq for ApiError {
fn eq(&self, other: &Self) -> bool {
self.code == other.code
&& self.title == other.title
&& self.status == other.status
&& self.detail == other.detail
&& self.errors == other.errors
&& self.rate_limit == other.rate_limit
&& self.causes == other.causes
&& {
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
{ self.extensions == other.extensions }
#[cfg(not(all(any(feature = "std", feature = "alloc"), feature = "serde")))]
true
}
&& {
#[cfg(feature = "uuid")]
{ self.request_id == other.request_id }
#[cfg(not(feature = "uuid"))]
true
}
}
}
#[cfg(all(
feature = "serde",
feature = "uuid",
any(feature = "std", feature = "alloc")
))]
mod uuid_urn_option {
use serde::{Deserialize, Deserializer, Serializer};
#[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(uuid: &Option<uuid::Uuid>, s: S) -> Result<S::Ok, S::Error> {
match uuid {
Some(id) => s.serialize_str(&format!("urn:uuid:{id}")),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<uuid::Uuid>, D::Error> {
let opt = Option::<String>::deserialize(d)?;
match opt {
None => Ok(None),
Some(ref urn) => {
let hex = urn.strip_prefix("urn:uuid:").ok_or_else(|| {
serde::de::Error::custom(format!("expected urn:uuid: prefix, got {urn}"))
})?;
hex.parse::<uuid::Uuid>()
.map(Some)
.map_err(serde::de::Error::custom)
}
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl ApiError {
pub fn new(code: ErrorCode, detail: impl Into<String>) -> Self {
let status = code.status_code();
debug_assert!(
(100..=599).contains(&status),
"status {status} is not a valid HTTP status code (RFC 9457 §3.1.3 requires 100–599)"
);
Self {
title: code.title().to_owned(),
status,
detail: detail.into(),
code,
#[cfg(feature = "uuid")]
request_id: None,
errors: Vec::new(),
rate_limit: None,
source: None,
causes: Vec::new(),
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
extensions: BTreeMap::new(),
}
}
#[cfg(feature = "uuid")]
#[must_use]
pub fn with_request_id(mut self, id: uuid::Uuid) -> Self {
self.request_id = Some(id);
self
}
#[must_use]
pub fn with_errors(mut self, errors: Vec<ValidationError>) -> Self {
self.errors = errors;
self
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[must_use]
pub fn with_source(mut self, source: impl core::error::Error + Send + Sync + 'static) -> Self {
self.source = Some(Arc::new(source));
self
}
#[must_use]
pub fn with_causes(mut self, causes: Vec<Self>) -> Self {
self.causes = causes;
self
}
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
#[must_use]
pub fn with_extension(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
self.extensions.insert(key.into(), value.into());
self
}
#[must_use]
pub fn status_code(&self) -> u16 {
self.status
}
#[must_use]
pub fn is_client_error(&self) -> bool {
self.status < 500
}
#[must_use]
pub fn is_server_error(&self) -> bool {
self.status >= 500
}
pub fn bad_request(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::BadRequest, msg)
}
pub fn validation_failed(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::ValidationFailed, msg)
}
pub fn unauthorized(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::Unauthorized, msg)
}
#[must_use]
pub fn invalid_credentials() -> Self {
Self::new(ErrorCode::InvalidCredentials, "Invalid credentials")
}
#[must_use]
pub fn token_expired() -> Self {
Self::new(ErrorCode::TokenExpired, "Token has expired")
}
pub fn forbidden(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::Forbidden, msg)
}
pub fn insufficient_permissions(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::InsufficientPermissions, msg)
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::ResourceNotFound, msg)
}
pub fn conflict(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::Conflict, msg)
}
pub fn already_exists(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::ResourceAlreadyExists, msg)
}
pub fn unprocessable(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::UnprocessableEntity, msg)
}
#[must_use]
pub fn rate_limited(retry_after_seconds: u64) -> Self {
Self::new(
ErrorCode::RateLimited,
format!("Rate limited, retry after {retry_after_seconds}s"),
)
}
#[must_use]
pub fn with_rate_limit(mut self, info: crate::ratelimit::RateLimitInfo) -> Self {
self.rate_limit = Some(info);
self
}
#[must_use]
pub fn rate_limited_with(info: crate::ratelimit::RateLimitInfo) -> Self {
let detail = match info.retry_after {
Some(secs) => format!("Rate limited, retry after {secs}s"),
None => "Rate limited".to_string(),
};
Self::new(ErrorCode::RateLimited, detail).with_rate_limit(info)
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::InternalServerError, msg)
}
pub fn unavailable(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::ServiceUnavailable, msg)
}
#[must_use]
pub fn builder() -> ApiErrorBuilder<(), ()> {
ApiErrorBuilder {
code: (),
detail: (),
#[cfg(feature = "uuid")]
request_id: None,
errors: Vec::new(),
causes: Vec::new(),
}
}
#[cfg(feature = "uuid")]
fn with_request_id_opt(mut self, id: Option<uuid::Uuid>) -> Self {
self.request_id = id;
self
}
#[cfg(not(feature = "uuid"))]
fn with_request_id_opt(self, _id: Option<()>) -> Self {
self
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
pub struct ApiErrorBuilder<C, D> {
code: C,
detail: D,
#[cfg(feature = "uuid")]
request_id: Option<uuid::Uuid>,
errors: Vec<ValidationError>,
causes: Vec<ApiError>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<D> ApiErrorBuilder<(), D> {
pub fn code(self, code: ErrorCode) -> ApiErrorBuilder<ErrorCode, D> {
ApiErrorBuilder {
code,
detail: self.detail,
#[cfg(feature = "uuid")]
request_id: self.request_id,
errors: self.errors,
causes: self.causes,
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<C> ApiErrorBuilder<C, ()> {
pub fn detail(self, detail: impl Into<String>) -> ApiErrorBuilder<C, String> {
ApiErrorBuilder {
code: self.code,
detail: detail.into(),
#[cfg(feature = "uuid")]
request_id: self.request_id,
errors: self.errors,
causes: self.causes,
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<C, D> ApiErrorBuilder<C, D> {
#[cfg(feature = "uuid")]
#[must_use]
pub fn request_id(mut self, id: uuid::Uuid) -> Self {
self.request_id = Some(id);
self
}
#[must_use]
pub fn errors(mut self, errors: Vec<ValidationError>) -> Self {
self.errors = errors;
self
}
#[must_use]
pub fn causes(mut self, causes: Vec<ApiError>) -> Self {
self.causes = causes;
self
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl ApiErrorBuilder<ErrorCode, String> {
#[must_use]
pub fn build(self) -> ApiError {
#[cfg(feature = "uuid")]
let built = ApiError::new(self.code, self.detail).with_request_id_opt(self.request_id);
#[cfg(not(feature = "uuid"))]
let built = ApiError::new(self.code, self.detail).with_request_id_opt(None::<()>);
built.with_errors(self.errors).with_causes(self.causes)
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code, self.detail)
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl core::error::Error for ApiError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
self.source
.as_deref()
.map(|s| s as &(dyn core::error::Error + 'static))
}
}
#[cfg(all(
feature = "proptest",
feature = "uuid",
any(feature = "std", feature = "alloc")
))]
impl proptest::arbitrary::Arbitrary for ApiError {
type Parameters = ();
type Strategy = proptest::strategy::BoxedStrategy<Self>;
fn arbitrary_with((): ()) -> Self::Strategy {
use proptest::prelude::*;
(
any::<ErrorCode>(),
any::<String>(),
any::<u16>(),
any::<String>(),
proptest::option::of(any::<u128>().prop_map(uuid::Uuid::from_u128)),
any::<Vec<ValidationError>>(),
)
.prop_map(|(code, title, status, detail, request_id, errors)| Self {
code,
title,
status,
detail,
#[cfg(feature = "uuid")]
request_id,
errors,
rate_limit: None,
source: None,
causes: Vec::new(),
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
extensions: BTreeMap::new(),
})
.boxed()
}
}
#[cfg(test)]
mod tests {
use super::*;
static MODE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct ModeGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
impl Drop for ModeGuard {
fn drop(&mut self) {
reset_error_type_mode();
}
}
fn lock_and_reset_mode() -> ModeGuard {
let guard = MODE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
reset_error_type_mode();
ModeGuard(guard)
}
#[test]
fn error_code_try_from_u16_non_error_returns_err() {
for code in [100_u16, 200, 204, 301, 302, 304] {
assert!(
ErrorCode::try_from(code).is_err(),
"expected Err for status {code}"
);
}
}
#[test]
fn error_code_try_from_u16_unmapped_4xx_returns_err() {
assert!(ErrorCode::try_from(418_u16).is_err());
}
#[test]
fn error_code_try_from_u16_roundtrip() {
let canonical_variants = [
ErrorCode::BadRequest,
ErrorCode::Unauthorized,
ErrorCode::Forbidden,
ErrorCode::ResourceNotFound,
ErrorCode::MethodNotAllowed,
ErrorCode::NotAcceptable,
ErrorCode::RequestTimeout,
ErrorCode::Conflict,
ErrorCode::Gone,
ErrorCode::PreconditionFailed,
ErrorCode::PayloadTooLarge,
ErrorCode::UnsupportedMediaType,
ErrorCode::UnprocessableEntity,
ErrorCode::PreconditionRequired,
ErrorCode::RateLimited,
ErrorCode::RequestHeaderFieldsTooLarge,
ErrorCode::InternalServerError,
ErrorCode::NotImplemented,
ErrorCode::BadGateway,
ErrorCode::ServiceUnavailable,
ErrorCode::GatewayTimeout,
];
for variant in &canonical_variants {
let status = variant.status_code();
let roundtripped =
ErrorCode::try_from(status).expect("canonical variant should round-trip");
assert_eq!(
roundtripped, *variant,
"roundtrip failed for {variant:?} (status {status})"
);
}
}
#[cfg(feature = "http")]
#[test]
fn error_code_try_from_status_code_non_error_returns_err() {
use http::StatusCode;
assert!(ErrorCode::try_from(StatusCode::OK).is_err());
assert!(ErrorCode::try_from(StatusCode::MOVED_PERMANENTLY).is_err());
}
#[cfg(feature = "http")]
#[test]
fn error_code_try_from_status_code_roundtrip() {
use http::StatusCode;
let pairs = [
(StatusCode::NOT_FOUND, ErrorCode::ResourceNotFound),
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::InternalServerError,
),
(StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited),
(StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
];
for (sc, expected) in &pairs {
assert_eq!(
ErrorCode::try_from(*sc),
Ok(expected.clone()),
"failed for {sc}"
);
}
}
#[test]
fn status_codes() {
assert_eq!(ApiError::bad_request("x").status_code(), 400);
assert_eq!(ApiError::unauthorized("x").status_code(), 401);
assert_eq!(ApiError::invalid_credentials().status_code(), 401);
assert_eq!(ApiError::token_expired().status_code(), 401);
assert_eq!(ApiError::forbidden("x").status_code(), 403);
assert_eq!(ApiError::not_found("x").status_code(), 404);
assert_eq!(ApiError::conflict("x").status_code(), 409);
assert_eq!(ApiError::already_exists("x").status_code(), 409);
assert_eq!(ApiError::unprocessable("x").status_code(), 422);
assert_eq!(ApiError::rate_limited(30).status_code(), 429);
assert_eq!(ApiError::internal("x").status_code(), 500);
assert_eq!(ApiError::unavailable("x").status_code(), 503);
}
#[test]
fn status_in_valid_http_range() {
for err in [
ApiError::bad_request("x"),
ApiError::unauthorized("x"),
ApiError::forbidden("x"),
ApiError::not_found("x"),
ApiError::conflict("x"),
ApiError::unprocessable("x"),
ApiError::rate_limited(30),
ApiError::internal("x"),
ApiError::unavailable("x"),
] {
assert!(
(100..=599).contains(&err.status),
"status {} out of RFC 9457 §3.1.3 range",
err.status
);
}
}
#[test]
fn error_code_urn() {
let _g = lock_and_reset_mode();
assert_eq!(
ErrorCode::ResourceNotFound.urn(),
"urn:api-bones:error:resource-not-found"
);
assert_eq!(
ErrorCode::ValidationFailed.urn(),
"urn:api-bones:error:validation-failed"
);
assert_eq!(
ErrorCode::InternalServerError.urn(),
"urn:api-bones:error:internal-server-error"
);
}
#[test]
fn error_code_from_type_uri_roundtrip() {
let _g = lock_and_reset_mode();
let codes = [
ErrorCode::BadRequest,
ErrorCode::ValidationFailed,
ErrorCode::Unauthorized,
ErrorCode::ResourceNotFound,
ErrorCode::InternalServerError,
ErrorCode::ServiceUnavailable,
];
for code in &codes {
let urn = code.urn();
assert_eq!(ErrorCode::from_type_uri(&urn).as_ref(), Some(code));
}
}
#[test]
fn error_code_from_type_uri_unknown() {
let _g = lock_and_reset_mode();
assert!(ErrorCode::from_type_uri("urn:api-bones:error:unknown-thing").is_none());
assert!(ErrorCode::from_type_uri("RESOURCE_NOT_FOUND").is_none());
}
#[test]
fn display_format() {
let _g = lock_and_reset_mode();
let e = ApiError::not_found("booking 123 not found");
assert_eq!(
e.to_string(),
"[urn:api-bones:error:resource-not-found] booking 123 not found"
);
}
#[test]
fn title_populated() {
let e = ApiError::not_found("x");
assert_eq!(e.title, "Resource Not Found");
}
#[cfg(feature = "uuid")]
#[test]
fn with_request_id() {
let id = uuid::Uuid::new_v4();
let e = ApiError::internal("oops").with_request_id(id);
assert_eq!(e.request_id, Some(id));
}
#[test]
fn with_errors() {
let e = ApiError::validation_failed("invalid input").with_errors(vec![ValidationError {
field: "/email".to_owned(),
message: "invalid format".to_owned(),
rule: Some("format".to_owned()),
}]);
assert!(!e.errors.is_empty());
assert_eq!(e.errors[0].field, "/email");
}
#[cfg(feature = "serde")]
#[test]
fn wire_format() {
let _g = lock_and_reset_mode();
let e = ApiError::not_found("booking 123 not found");
let json = serde_json::to_value(&e).unwrap();
assert!(json.get("error").is_none());
assert_eq!(json["type"], "urn:api-bones:error:resource-not-found");
assert_eq!(json["title"], "Resource Not Found");
assert_eq!(json["status"], 404);
assert_eq!(json["detail"], "booking 123 not found");
assert!(json.get("instance").is_none());
assert!(json.get("errors").is_none());
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn wire_format_instance_is_urn_uuid() {
let _g = lock_and_reset_mode();
let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let e = ApiError::internal("oops").with_request_id(id);
let json = serde_json::to_value(&e).unwrap();
assert_eq!(
json["instance"],
"urn:uuid:550e8400-e29b-41d4-a716-446655440000"
);
assert!(json.get("request_id").is_none());
}
#[cfg(feature = "serde")]
#[test]
fn wire_format_with_errors() {
let _g = lock_and_reset_mode();
let e = ApiError::validation_failed("bad input").with_errors(vec![ValidationError {
field: "/name".to_owned(),
message: "required".to_owned(),
rule: None,
}]);
let json = serde_json::to_value(&e).unwrap();
assert_eq!(json["type"], "urn:api-bones:error:validation-failed");
assert_eq!(json["status"], 400);
assert!(json["errors"].is_array());
assert_eq!(json["errors"][0]["field"], "/name");
}
#[cfg(feature = "serde")]
#[test]
fn snapshot_not_found() {
let _g = lock_and_reset_mode();
let e = ApiError::not_found("booking 123 not found");
let json = serde_json::to_value(&e).unwrap();
let expected = serde_json::json!({
"type": "urn:api-bones:error:resource-not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "booking 123 not found"
});
assert_eq!(json, expected);
}
#[cfg(feature = "serde")]
#[test]
fn snapshot_validation_failed_with_errors() {
let _g = lock_and_reset_mode();
let e = ApiError::validation_failed("invalid input").with_errors(vec![
ValidationError {
field: "/email".to_owned(),
message: "invalid format".to_owned(),
rule: Some("format".to_owned()),
},
ValidationError {
field: "/name".to_owned(),
message: "required".to_owned(),
rule: None,
},
]);
let json = serde_json::to_value(&e).unwrap();
let expected = serde_json::json!({
"type": "urn:api-bones:error:validation-failed",
"title": "Validation Failed",
"status": 400,
"detail": "invalid input",
"errors": [
{"field": "/email", "message": "invalid format", "rule": "format"},
{"field": "/name", "message": "required"}
]
});
assert_eq!(json, expected);
}
#[cfg(feature = "serde")]
#[test]
fn error_code_serde_roundtrip() {
let _g = lock_and_reset_mode();
let code = ErrorCode::ResourceNotFound;
let json = serde_json::to_value(&code).unwrap();
assert_eq!(json, "urn:api-bones:error:resource-not-found");
let back: ErrorCode = serde_json::from_value(json).unwrap();
assert_eq!(back, code);
}
#[test]
fn client_vs_server() {
assert!(ApiError::not_found("x").is_client_error());
assert!(!ApiError::not_found("x").is_server_error());
assert!(ApiError::internal("x").is_server_error());
}
#[test]
fn error_type_mode_render_url() {
let mode = ErrorTypeMode::Url {
base_url: "https://docs.example.com/errors".into(),
};
assert_eq!(
mode.render("resource-not-found"),
"https://docs.example.com/errors/resource-not-found"
);
let mode_slash = ErrorTypeMode::Url {
base_url: "https://docs.example.com/errors/".into(),
};
assert_eq!(
mode_slash.render("bad-request"),
"https://docs.example.com/errors/bad-request"
);
}
#[test]
fn set_error_type_mode_url_and_urn_namespace_fallback() {
let _g = lock_and_reset_mode();
set_error_type_mode(ErrorTypeMode::Url {
base_url: "https://docs.test.com/errors".into(),
});
assert_eq!(
error_type_mode(),
ErrorTypeMode::Url {
base_url: "https://docs.test.com/errors".into()
}
);
assert_eq!(urn_namespace(), "api-bones");
}
#[test]
fn urn_namespace_urn_mode_returns_namespace() {
let _g = lock_and_reset_mode();
assert_eq!(urn_namespace(), "api-bones");
}
#[allow(unsafe_code)]
#[test]
fn error_type_mode_url_from_runtime_env() {
let _g = lock_and_reset_mode();
unsafe {
std::env::set_var(
"SHARED_TYPES_ERROR_TYPE_BASE_URL",
"https://env.example.com/errors",
);
}
let mode = error_type_mode();
assert!(
matches!(mode, ErrorTypeMode::Url { base_url } if base_url == "https://env.example.com/errors")
);
unsafe {
std::env::remove_var("SHARED_TYPES_ERROR_TYPE_BASE_URL");
}
}
#[allow(unsafe_code)]
#[test]
fn error_type_mode_urn_from_runtime_env() {
let _g = lock_and_reset_mode();
unsafe {
std::env::set_var("SHARED_TYPES_URN_NAMESPACE", "testapp");
}
let mode = error_type_mode();
assert!(matches!(mode, ErrorTypeMode::Urn { namespace } if namespace == "testapp"));
unsafe {
std::env::remove_var("SHARED_TYPES_URN_NAMESPACE");
}
}
#[test]
fn from_type_uri_url_mode_paths() {
let _g = lock_and_reset_mode();
set_error_type_mode(ErrorTypeMode::Url {
base_url: "https://docs.test.com/errors".into(),
});
assert_eq!(
ErrorCode::from_type_uri("https://docs.test.com/errors/resource-not-found"),
Some(ErrorCode::ResourceNotFound)
);
assert_eq!(
ErrorCode::from_type_uri("urn:api-bones:error:bad-request"),
Some(ErrorCode::BadRequest)
);
assert!(ErrorCode::from_type_uri("https://docs.test.com/errors/totally-unknown").is_none());
assert!(ErrorCode::from_type_uri("not-a-url-or-urn").is_none());
}
#[test]
#[allow(clippy::too_many_lines)]
fn all_error_code_variants_title_slug_status() {
let _g = lock_and_reset_mode();
let cases: &[(ErrorCode, &str, &str, u16)] = &[
(ErrorCode::BadRequest, "Bad Request", "bad-request", 400),
(
ErrorCode::ValidationFailed,
"Validation Failed",
"validation-failed",
400,
),
(ErrorCode::Unauthorized, "Unauthorized", "unauthorized", 401),
(
ErrorCode::InvalidCredentials,
"Invalid Credentials",
"invalid-credentials",
401,
),
(
ErrorCode::TokenExpired,
"Token Expired",
"token-expired",
401,
),
(
ErrorCode::TokenInvalid,
"Token Invalid",
"token-invalid",
401,
),
(ErrorCode::Forbidden, "Forbidden", "forbidden", 403),
(
ErrorCode::InsufficientPermissions,
"Insufficient Permissions",
"insufficient-permissions",
403,
),
(
ErrorCode::ResourceNotFound,
"Resource Not Found",
"resource-not-found",
404,
),
(
ErrorCode::MethodNotAllowed,
"Method Not Allowed",
"method-not-allowed",
405,
),
(
ErrorCode::NotAcceptable,
"Not Acceptable",
"not-acceptable",
406,
),
(
ErrorCode::RequestTimeout,
"Request Timeout",
"request-timeout",
408,
),
(ErrorCode::Conflict, "Conflict", "conflict", 409),
(
ErrorCode::ResourceAlreadyExists,
"Resource Already Exists",
"resource-already-exists",
409,
),
(ErrorCode::Gone, "Gone", "gone", 410),
(
ErrorCode::PreconditionFailed,
"Precondition Failed",
"precondition-failed",
412,
),
(
ErrorCode::PayloadTooLarge,
"Payload Too Large",
"payload-too-large",
413,
),
(
ErrorCode::UnsupportedMediaType,
"Unsupported Media Type",
"unsupported-media-type",
415,
),
(
ErrorCode::UnprocessableEntity,
"Unprocessable Entity",
"unprocessable-entity",
422,
),
(
ErrorCode::PreconditionRequired,
"Precondition Required",
"precondition-required",
428,
),
(ErrorCode::RateLimited, "Rate Limited", "rate-limited", 429),
(
ErrorCode::RequestHeaderFieldsTooLarge,
"Request Header Fields Too Large",
"request-header-fields-too-large",
431,
),
(
ErrorCode::InternalServerError,
"Internal Server Error",
"internal-server-error",
500,
),
(
ErrorCode::NotImplemented,
"Not Implemented",
"not-implemented",
501,
),
(ErrorCode::BadGateway, "Bad Gateway", "bad-gateway", 502),
(
ErrorCode::ServiceUnavailable,
"Service Unavailable",
"service-unavailable",
503,
),
(
ErrorCode::GatewayTimeout,
"Gateway Timeout",
"gateway-timeout",
504,
),
];
for (code, title, slug, status) in cases {
assert_eq!(code.title(), *title, "title mismatch for {slug}");
assert_eq!(code.urn_slug(), *slug, "slug mismatch");
assert_eq!(code.status_code(), *status, "status mismatch for {slug}");
let urn = code.urn();
assert_eq!(
ErrorCode::from_type_uri(&urn).as_ref(),
Some(code),
"from_type_uri roundtrip failed for {urn}"
);
}
}
#[test]
fn insufficient_permissions_constructor() {
let e = ApiError::insufficient_permissions("missing admin role");
assert_eq!(e.status_code(), 403);
assert_eq!(e.title, "Insufficient Permissions");
assert!(e.is_client_error());
}
#[cfg(feature = "serde")]
#[test]
fn error_code_deserialize_non_string_is_error() {
let _g = lock_and_reset_mode();
let result: Result<ErrorCode, _> = serde_json::from_value(serde_json::json!(42));
assert!(result.is_err());
}
#[cfg(feature = "serde")]
#[test]
fn error_code_deserialize_unknown_uri_is_error() {
let _g = lock_and_reset_mode();
let result: Result<ErrorCode, _> =
serde_json::from_value(serde_json::json!("urn:api-bones:error:does-not-exist"));
assert!(result.is_err());
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn uuid_urn_option_serialize_none_produces_null() {
use serde_json::Serializer as JsonSerializer;
let mut buf = Vec::new();
let mut s = JsonSerializer::new(&mut buf);
uuid_urn_option::serialize(&None, &mut s).unwrap();
assert_eq!(buf, b"null");
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn uuid_urn_option_deserialize_non_string_is_error() {
let _g = lock_and_reset_mode();
let json = serde_json::json!({
"type": "urn:api-bones:error:bad-request",
"title": "Bad Request",
"status": 400,
"detail": "x",
"instance": 42
});
let result: Result<ApiError, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn uuid_urn_option_deserialize_null_gives_none() {
let _g = lock_and_reset_mode();
let json = serde_json::json!({
"type": "urn:api-bones:error:bad-request",
"title": "Bad Request",
"status": 400,
"detail": "x",
"instance": null
});
let e: ApiError = serde_json::from_value(json).unwrap();
assert!(e.request_id.is_none());
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn uuid_urn_option_deserialize_valid_urn_uuid() {
let _g = lock_and_reset_mode();
let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let json = serde_json::json!({
"type": "urn:api-bones:error:bad-request",
"title": "Bad Request",
"status": 400,
"detail": "x",
"instance": "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
});
let e: ApiError = serde_json::from_value(json).unwrap();
assert_eq!(e.request_id, Some(id));
}
#[cfg(all(feature = "serde", feature = "uuid"))]
#[test]
fn uuid_urn_option_deserialize_bad_prefix_is_error() {
let _g = lock_and_reset_mode();
let json = serde_json::json!({
"type": "urn:api-bones:error:bad-request",
"title": "Bad Request",
"status": 400,
"detail": "x",
"instance": "uuid:550e8400-e29b-41d4-a716-446655440000"
});
let result: Result<ApiError, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[cfg(feature = "uuid")]
#[test]
fn builder_basic() {
let err = ApiError::builder()
.code(ErrorCode::ResourceNotFound)
.detail("Booking 123 not found")
.build();
assert_eq!(err.status, 404);
assert_eq!(err.title, "Resource Not Found");
assert_eq!(err.detail, "Booking 123 not found");
assert!(err.request_id.is_none());
assert!(err.errors.is_empty());
}
#[test]
fn builder_equivalence_with_new() {
let via_new = ApiError::new(ErrorCode::BadRequest, "bad");
let via_builder = ApiError::builder()
.code(ErrorCode::BadRequest)
.detail("bad")
.build();
assert_eq!(via_new, via_builder);
}
#[cfg(feature = "uuid")]
#[test]
fn builder_chaining_all_optionals() {
let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let errs = vec![ValidationError {
field: "/email".to_owned(),
message: "invalid".to_owned(),
rule: None,
}];
let err = ApiError::builder()
.code(ErrorCode::ValidationFailed)
.detail("invalid input")
.request_id(id)
.errors(errs.clone())
.build();
assert_eq!(err.request_id, Some(id));
assert_eq!(err.errors, errs);
}
#[test]
fn builder_detail_before_code() {
let err = ApiError::builder()
.detail("forbidden action")
.code(ErrorCode::Forbidden)
.build();
assert_eq!(err.status, 403);
assert_eq!(err.detail, "forbidden action");
}
#[test]
fn api_error_source_none_by_default() {
use std::error::Error;
let err = ApiError::not_found("booking 42");
assert!(err.source().is_none());
}
#[test]
fn api_error_with_source_chain_is_walkable() {
use std::error::Error;
#[derive(Debug)]
struct RootCause;
impl std::fmt::Display for RootCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("database connection refused")
}
}
impl Error for RootCause {}
let err = ApiError::internal("upstream failure").with_source(RootCause);
let source = err.source().expect("source should be set");
assert_eq!(source.to_string(), "database connection refused");
assert!(source.source().is_none());
}
#[test]
fn api_error_source_chain_two_levels() {
use std::error::Error;
#[derive(Debug)]
struct Mid(std::io::Error);
impl std::fmt::Display for Mid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "mid: {}", self.0)
}
}
impl Error for Mid {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
let mid = Mid(io_err);
let err = ApiError::unavailable("service down").with_source(mid);
let hop1 = err.source().expect("first source");
assert!(hop1.to_string().starts_with("mid:"));
let hop2 = hop1.source().expect("second source");
assert_eq!(hop2.to_string(), "timed out");
}
#[test]
fn api_error_partial_eq_ignores_source() {
#[derive(Debug)]
struct Cause;
impl std::fmt::Display for Cause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("cause")
}
}
impl std::error::Error for Cause {}
assert_eq!(Cause.to_string(), "cause");
let a = ApiError::not_found("x");
let b = ApiError::not_found("x").with_source(Cause);
assert_eq!(a, b);
}
#[test]
fn api_error_with_source_is_cloneable() {
use std::error::Error;
#[derive(Debug)]
struct Cause;
impl std::fmt::Display for Cause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("cause")
}
}
impl Error for Cause {}
assert_eq!(Cause.to_string(), "cause");
let a = ApiError::internal("oops").with_source(Cause);
let b = a.clone();
assert!(a.source().is_some());
assert!(b.source().is_some());
}
#[test]
fn validation_error_display_with_rule() {
let ve = ValidationError {
field: "/email".to_owned(),
message: "invalid format".to_owned(),
rule: Some("format".to_owned()),
};
assert_eq!(ve.to_string(), "/email: invalid format (rule: format)");
}
#[test]
fn validation_error_display_without_rule() {
let ve = ValidationError {
field: "/name".to_owned(),
message: "required".to_owned(),
rule: None,
};
assert_eq!(ve.to_string(), "/name: required");
}
#[test]
fn validation_error_is_std_error() {
use std::error::Error;
let ve = ValidationError {
field: "/age".to_owned(),
message: "must be positive".to_owned(),
rule: Some("min".to_owned()),
};
assert!(ve.source().is_none());
let _: &dyn Error = &ve;
}
#[test]
fn api_error_source_downcast() {
use std::error::Error;
use std::sync::Arc;
#[derive(Debug)]
struct Typed(u32);
impl std::fmt::Display for Typed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "typed({})", self.0)
}
}
impl Error for Typed {}
assert_eq!(Typed(7).to_string(), "typed(7)");
let err = ApiError::internal("oops").with_source(Typed(42));
let source_arc: &Arc<dyn Error + Send + Sync> = err.source.as_ref().expect("source set");
let downcasted = source_arc.downcast_ref::<Typed>();
assert!(downcasted.is_some());
assert_eq!(downcasted.unwrap().0, 42);
}
#[cfg(feature = "schemars")]
#[test]
fn error_code_schema_is_valid() {
let schema = schemars::schema_for!(ErrorCode);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object(), "schema should be a JSON object");
}
#[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
#[test]
fn api_error_schema_is_valid() {
let schema = schemars::schema_for!(ApiError);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
assert!(
json.get("definitions").is_some()
|| json.get("$defs").is_some()
|| json.get("properties").is_some(),
"schema should contain definitions or properties"
);
}
#[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
#[test]
fn validation_error_schema_is_valid() {
let schema = schemars::schema_for!(ValidationError);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
}
#[test]
fn http_error_blanket_from() {
#[derive(Debug)]
struct NotFound(u64);
impl HttpError for NotFound {
fn status_code(&self) -> u16 {
404
}
fn error_code(&self) -> ErrorCode {
ErrorCode::ResourceNotFound
}
fn detail(&self) -> String {
format!("item {} not found", self.0)
}
}
assert_eq!(NotFound(99).status_code(), 404);
let err: ApiError = NotFound(99).into();
assert_eq!(err.status, 404);
assert_eq!(err.code, ErrorCode::ResourceNotFound);
assert_eq!(err.detail, "item 99 not found");
}
#[test]
fn with_causes_roundtrip() {
let cause = ApiError::not_found("upstream missing");
let err = ApiError::internal("pipeline failed").with_causes(vec![cause.clone()]);
assert_eq!(err.causes.len(), 1);
assert_eq!(err.causes[0].detail, cause.detail);
}
#[cfg(feature = "serde")]
#[test]
fn causes_serialized_as_extension() {
let _g = lock_and_reset_mode();
let cause = ApiError::not_found("db row missing");
let err = ApiError::internal("handler failed").with_causes(vec![cause]);
let json = serde_json::to_value(&err).unwrap();
let causes = json["causes"].as_array().expect("causes must be array");
assert_eq!(causes.len(), 1);
assert_eq!(causes[0]["status"], 404);
assert_eq!(causes[0]["detail"], "db row missing");
}
#[cfg(feature = "serde")]
#[test]
fn causes_omitted_when_empty() {
let _g = lock_and_reset_mode();
let err = ApiError::internal("oops");
let json = serde_json::to_value(&err).unwrap();
assert!(json.get("causes").is_none());
}
#[cfg(feature = "serde")]
#[test]
fn causes_propagated_through_problem_json() {
use crate::error::ProblemJson;
let _g = lock_and_reset_mode();
let cause = ApiError::not_found("missing row");
let err = ApiError::internal("failed").with_causes(vec![cause]);
let p = ProblemJson::from(err);
assert!(p.extensions.contains_key("causes"));
let causes = p.extensions["causes"].as_array().unwrap();
assert_eq!(causes.len(), 1);
assert_eq!(causes[0]["status"], 404);
}
#[test]
fn builder_with_causes() {
let cause = ApiError::bad_request("bad input");
let err = ApiError::builder()
.code(ErrorCode::UnprocessableEntity)
.detail("entity failed")
.causes(vec![cause.clone()])
.build();
assert_eq!(err.causes.len(), 1);
assert_eq!(err.causes[0].detail, cause.detail);
}
#[cfg(feature = "serde")]
#[test]
fn with_extension_roundtrip() {
let _g = lock_and_reset_mode();
let err = ApiError::internal("boom").with_extension("trace_id", "abc-123");
assert_eq!(err.extensions["trace_id"], "abc-123");
}
#[cfg(feature = "serde")]
#[test]
fn extension_flattened_in_wire_format() {
let _g = lock_and_reset_mode();
let err = ApiError::not_found("gone").with_extension("tenant", "acme");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["tenant"], "acme");
assert_eq!(json["status"], 404);
}
#[cfg(feature = "serde")]
#[test]
fn extension_roundtrip_ser_de() {
let _g = lock_and_reset_mode();
let err = ApiError::bad_request("bad").with_extension("request_num", 42_u64);
let json = serde_json::to_value(&err).unwrap();
let back: ApiError = serde_json::from_value(json).unwrap();
assert_eq!(back.extensions["request_num"], 42_u64);
}
#[cfg(feature = "serde")]
#[test]
fn extension_propagated_through_problem_json() {
use crate::error::ProblemJson;
let _g = lock_and_reset_mode();
let err = ApiError::forbidden("denied").with_extension("policy", "read-only");
let p = ProblemJson::from(err);
assert_eq!(p.extensions["policy"], "read-only");
}
}
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct ProblemJson {
#[cfg_attr(feature = "serde", serde(rename = "type"))]
pub r#type: String,
pub title: String,
pub status: u16,
pub detail: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub instance: Option<String>,
#[cfg_attr(feature = "serde", serde(flatten))]
#[cfg_attr(feature = "schemars", schemars(skip))]
#[cfg_attr(feature = "arbitrary", arbitrary(default))]
#[cfg_attr(
feature = "proptest",
proptest(strategy = "proptest::strategy::Just(BTreeMap::new())")
)]
pub extensions: BTreeMap<String, serde_json::Value>,
}
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
impl ProblemJson {
#[must_use]
pub fn new(
r#type: impl Into<String>,
title: impl Into<String>,
status: u16,
detail: impl Into<String>,
) -> Self {
Self {
r#type: r#type.into(),
title: title.into(),
status,
detail: detail.into(),
instance: None,
extensions: BTreeMap::new(),
}
}
#[must_use]
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
pub fn extend(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
self.extensions.insert(key.into(), value.into());
}
}
#[cfg(all(feature = "std", feature = "serde"))]
impl From<ApiError> for ProblemJson {
fn from(err: ApiError) -> Self {
let mut p = Self::new(err.code.urn(), err.title, err.status, err.detail);
#[cfg(feature = "uuid")]
if let Some(id) = err.request_id {
p.instance = Some(format!("urn:uuid:{id}"));
}
if !err.errors.is_empty() {
let errs =
serde_json::to_value(&err.errors).unwrap_or(serde_json::Value::Array(vec![]));
p.extensions.insert("errors".into(), errs);
}
if let Some(info) = err.rate_limit
&& let Ok(v) = serde_json::to_value(&info)
{
p.extensions.insert("rate_limit".into(), v);
}
if !err.causes.is_empty() {
let causes: Vec<serde_json::Value> = err
.causes
.into_iter()
.map(|c| {
let cp = Self::from(c);
serde_json::to_value(cp).unwrap_or(serde_json::Value::Null)
})
.collect();
p.extensions
.insert("causes".into(), serde_json::Value::Array(causes));
}
for (k, v) in err.extensions {
p.extensions.insert(k, v);
}
p
}
}
#[cfg(all(feature = "std", feature = "serde", test))]
mod problem_json_tests {
use super::*;
#[test]
fn new_sets_fields_and_empty_extensions() {
let p = ProblemJson::new(
"urn:api-bones:error:bad-request",
"Bad Request",
400,
"missing email",
);
assert_eq!(p.r#type, "urn:api-bones:error:bad-request");
assert_eq!(p.title, "Bad Request");
assert_eq!(p.status, 400);
assert_eq!(p.detail, "missing email");
assert!(p.instance.is_none());
assert!(p.extensions.is_empty());
}
#[test]
fn with_instance_sets_instance() {
let p = ProblemJson::new("urn:t", "T", 400, "d")
.with_instance("urn:uuid:00000000-0000-0000-0000-000000000000");
assert_eq!(
p.instance.as_deref(),
Some("urn:uuid:00000000-0000-0000-0000-000000000000")
);
}
#[test]
fn extend_inserts_entry() {
let mut p = ProblemJson::new("urn:t", "T", 400, "d");
p.extend("trace_id", "abc123");
assert_eq!(p.extensions["trace_id"], "abc123");
}
#[test]
fn from_api_error_maps_standard_fields() {
#[cfg(feature = "std")]
let _ = super::super::error_type_mode(); let err = ApiError::new(ErrorCode::Forbidden, "not allowed");
let p = ProblemJson::from(err);
assert_eq!(p.status, 403);
assert_eq!(p.title, "Forbidden");
assert_eq!(p.detail, "not allowed");
}
#[test]
fn from_api_error_maps_rate_limit_to_extension() {
use crate::ratelimit::RateLimitInfo;
let info = RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(30);
let err = ApiError::rate_limited_with(info);
let p = ProblemJson::from(err);
assert!(p.extensions.contains_key("rate_limit"));
let rl = &p.extensions["rate_limit"];
assert_eq!(rl["limit"], 100);
assert_eq!(rl["remaining"], 0);
assert_eq!(rl["reset"], 1_700_000_000_u64);
assert_eq!(rl["retry_after"], 30);
}
#[test]
fn api_error_rate_limit_serializes_inline() {
use crate::ratelimit::RateLimitInfo;
let err = ApiError::rate_limited(60)
.with_rate_limit(RateLimitInfo::new(100, 0, 1_700_000_000).retry_after(60));
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["rate_limit"]["limit"], 100);
assert_eq!(json["rate_limit"]["retry_after"], 60);
}
#[test]
fn api_error_rate_limit_omitted_when_none() {
let err = ApiError::bad_request("x");
let json = serde_json::to_value(&err).unwrap();
assert!(json.get("rate_limit").is_none());
}
#[test]
fn from_api_error_maps_validation_errors_to_extension() {
let err = ApiError::new(ErrorCode::ValidationFailed, "bad input").with_errors(vec![
ValidationError {
field: "/email".into(),
message: "invalid".into(),
rule: None,
},
]);
let p = ProblemJson::from(err);
assert!(p.extensions.contains_key("errors"));
let errs = p.extensions["errors"].as_array().unwrap();
assert_eq!(errs.len(), 1);
assert_eq!(errs[0]["field"], "/email");
}
#[cfg(feature = "uuid")]
#[test]
fn from_api_error_maps_request_id_to_instance() {
let id = uuid::Uuid::nil();
let err = ApiError::new(ErrorCode::BadRequest, "x").with_request_id(id);
let p = ProblemJson::from(err);
assert_eq!(
p.instance.as_deref(),
Some("urn:uuid:00000000-0000-0000-0000-000000000000")
);
}
#[test]
fn serializes_extensions_flat() {
let mut p = ProblemJson::new("urn:t", "T", 400, "d");
p.extend("trace_id", "xyz");
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert_eq!(json["trace_id"], "xyz");
assert!(json.get("extensions").is_none());
}
#[test]
fn instance_omitted_when_none() {
let p = ProblemJson::new("urn:t", "T", 400, "d");
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert!(json.get("instance").is_none());
}
}
#[cfg(feature = "axum")]
mod axum_impl {
use super::ApiError;
use axum::response::{IntoResponse, Response};
use http::{HeaderValue, StatusCode};
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status =
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_string(&self).expect("ApiError serialization is infallible");
let mut response = (status, body).into_response();
response.headers_mut().insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
response
}
}
#[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
impl IntoResponse for super::ProblemJson {
fn into_response(self) -> Response {
let status =
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body =
serde_json::to_string(&self).expect("ProblemJson serialization is infallible");
let mut response = (status, body).into_response();
response.headers_mut().insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
response
}
}
}
#[cfg(all(test, feature = "axum"))]
mod axum_tests {
use super::*;
use axum::response::IntoResponse;
use http::StatusCode;
#[tokio::test]
async fn into_response_status_and_content_type() {
reset_error_type_mode();
let err = ApiError::not_found("thing 42 not found");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/problem+json"
);
}
#[tokio::test]
async fn into_response_body() {
reset_error_type_mode();
let err = ApiError::unauthorized("bad token");
let response = err.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["type"], "urn:api-bones:error:unauthorized");
assert_eq!(json["status"], 401);
assert_eq!(json["detail"], "bad token");
}
#[cfg(feature = "utoipa")]
#[test]
fn error_code_schema_is_string_type() {
use utoipa::PartialSchema as _;
use utoipa::openapi::schema::Schema;
let schema_ref = ErrorCode::schema();
let schema = match schema_ref {
utoipa::openapi::RefOr::T(s) => s,
utoipa::openapi::RefOr::Ref(_) => panic!("expected inline schema"),
};
assert!(
matches!(schema, Schema::Object(_)),
"ErrorCode schema should be an object (string type)"
);
}
#[cfg(feature = "utoipa")]
#[test]
fn error_code_schema_name() {
use utoipa::ToSchema as _;
assert_eq!(ErrorCode::name(), "ErrorCode");
}
#[cfg(feature = "serde")]
#[tokio::test]
async fn problem_json_into_response_status_and_content_type() {
use super::ProblemJson;
let p = ProblemJson::new("urn:api-bones:error:not-found", "Not Found", 404, "gone");
let response = p.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/problem+json"
);
}
#[cfg(feature = "serde")]
#[tokio::test]
async fn problem_json_into_response_body_with_extension() {
use super::ProblemJson;
let mut p = ProblemJson::new(
"urn:api-bones:error:bad-request",
"Bad Request",
400,
"missing field",
);
p.extend("trace_id", "abc123");
let response = p.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["type"], "urn:api-bones:error:bad-request");
assert_eq!(json["status"], 400);
assert_eq!(json["trace_id"], "abc123");
assert!(json.get("extensions").is_none());
}
#[cfg(feature = "serde")]
#[tokio::test]
async fn problem_json_instance_omitted_when_none() {
use super::ProblemJson;
let p = ProblemJson::new("urn:t", "T", 500, "d");
let response = p.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("instance").is_none());
}
#[test]
fn rate_limited_with_no_retry_after() {
use crate::ratelimit::RateLimitInfo;
let info = RateLimitInfo::new(100, 5, 1_700_000_000);
let err = ApiError::rate_limited_with(info);
assert_eq!(err.status, 429);
assert_eq!(err.detail, "Rate limited");
assert!(err.rate_limit.is_some());
}
}