use std::{error::Error, fmt};
pub type SoapResult<T> = Result<T, SoapError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SoapErrorKind {
NotFound,
Validation,
Conflict,
Unauthorized,
Forbidden,
Domain,
Unsupported,
Timeout,
Unavailable,
Infrastructure,
}
impl SoapErrorKind {
const fn default_transience(self) -> ErrorTransience {
match self {
Self::Timeout | Self::Unavailable => ErrorTransience::Transient,
Self::Infrastructure => ErrorTransience::Unknown,
Self::NotFound
| Self::Validation
| Self::Conflict
| Self::Unauthorized
| Self::Forbidden
| Self::Domain
| Self::Unsupported => ErrorTransience::Permanent,
}
}
pub const fn is_reportable(self) -> bool {
matches!(
self,
Self::Timeout | Self::Unavailable | Self::Infrastructure
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorTransience {
Permanent,
Transient,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiagnosticId(String);
impl DiagnosticId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DiagnosticId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl From<String> for DiagnosticId {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for DiagnosticId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
#[derive(Debug)]
pub struct SoapError {
kind: SoapErrorKind,
message: String,
transience: ErrorTransience,
diagnostic_id: Option<DiagnosticId>,
source: Option<Box<dyn Error + Send + Sync + 'static>>,
}
impl SoapError {
pub fn new(kind: SoapErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
transience: kind.default_transience(),
diagnostic_id: None,
source: None,
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::NotFound, message)
}
pub fn validation(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Validation, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Conflict, message)
}
pub fn unauthorized() -> Self {
Self::new(SoapErrorKind::Unauthorized, "unauthorized")
}
pub fn forbidden() -> Self {
Self::new(SoapErrorKind::Forbidden, "forbidden")
}
pub fn domain(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Domain, message)
}
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Unsupported, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Timeout, message)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Unavailable, message)
}
pub fn infrastructure(message: impl Into<String>) -> Self {
Self::new(SoapErrorKind::Infrastructure, message)
}
#[must_use]
pub fn with_source<E>(mut self, source: E) -> Self
where
E: Error + Send + Sync + 'static,
{
self.source = Some(Box::new(source));
self
}
#[must_use]
pub const fn with_transience(mut self, transience: ErrorTransience) -> Self {
self.transience = transience;
self
}
#[must_use]
pub fn with_diagnostic_id(mut self, diagnostic_id: impl Into<DiagnosticId>) -> Self {
self.diagnostic_id = Some(diagnostic_id.into());
self
}
pub const fn kind(&self) -> SoapErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub const fn transience(&self) -> ErrorTransience {
self.transience
}
pub fn diagnostic_id(&self) -> Option<&DiagnosticId> {
self.diagnostic_id.as_ref()
}
pub const fn is_transient(&self) -> bool {
matches!(self.transience, ErrorTransience::Transient)
}
pub const fn is_reportable(&self) -> bool {
self.kind.is_reportable()
}
}
impl fmt::Display for SoapError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
SoapErrorKind::NotFound => write!(formatter, "not found: {}", self.message),
SoapErrorKind::Validation => {
write!(formatter, "validation failed: {}", self.message)
}
SoapErrorKind::Conflict => write!(formatter, "conflict: {}", self.message),
SoapErrorKind::Unauthorized | SoapErrorKind::Forbidden | SoapErrorKind::Domain => {
formatter.write_str(&self.message)
}
SoapErrorKind::Unsupported => write!(formatter, "unsupported: {}", self.message),
SoapErrorKind::Timeout => write!(formatter, "timeout: {}", self.message),
SoapErrorKind::Unavailable => write!(formatter, "unavailable: {}", self.message),
SoapErrorKind::Infrastructure => {
write!(formatter, "infrastructure error: {}", self.message)
}
}
}
}
impl Error for SoapError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn Error + 'static))
}
}
#[cfg(test)]
mod tests {
use std::{error::Error, io};
use super::{DiagnosticId, ErrorTransience, SoapError, SoapErrorKind};
#[test]
fn stable_kinds_have_expected_reporting_and_transience_defaults() {
let validation = SoapError::validation("name is empty");
assert_eq!(validation.kind(), SoapErrorKind::Validation);
assert_eq!(validation.transience(), ErrorTransience::Permanent);
assert!(!validation.is_reportable());
assert!(!validation.is_transient());
let timeout = SoapError::timeout("database query");
assert_eq!(timeout.kind(), SoapErrorKind::Timeout);
assert_eq!(timeout.transience(), ErrorTransience::Transient);
assert!(timeout.is_reportable());
assert!(timeout.is_transient());
let infrastructure = SoapError::infrastructure("database operation failed");
assert_eq!(infrastructure.transience(), ErrorTransience::Unknown);
assert!(infrastructure.is_reportable());
}
#[test]
fn original_source_is_preserved_but_not_exposed_by_display() {
let error = SoapError::unavailable("user database is unavailable").with_source(
io::Error::new(io::ErrorKind::ConnectionRefused, "secret driver detail"),
);
let Some(source) = error.source() else {
panic!("source must be preserved");
};
assert_eq!(source.to_string(), "secret driver detail");
assert_eq!(
error.to_string(),
"unavailable: user database is unavailable"
);
assert!(!error.to_string().contains("secret driver detail"));
}
#[test]
fn mapped_business_error_can_keep_technical_source() {
let error = SoapError::conflict("email already exists").with_source(io::Error::new(
io::ErrorKind::AlreadyExists,
"unique constraint users_email_key",
));
assert_eq!(error.kind(), SoapErrorKind::Conflict);
assert!(!error.is_reportable());
assert_eq!(
error.source().map(ToString::to_string),
Some("unique constraint users_email_key".into())
);
}
#[test]
fn diagnostic_identifier_is_opaque_and_optional() {
let error = SoapError::infrastructure("storage failed")
.with_diagnostic_id(DiagnosticId::new("0195d6b4-test"));
assert_eq!(
error.diagnostic_id().map(DiagnosticId::as_str),
Some("0195d6b4-test")
);
}
#[test]
fn adapter_can_override_transience_without_changing_kind() {
let error = SoapError::infrastructure("serialization failure")
.with_transience(ErrorTransience::Transient);
assert_eq!(error.kind(), SoapErrorKind::Infrastructure);
assert!(error.is_transient());
}
}