soaprs-core 0.2.0

Core contracts for soaprs
Documentation
//! Error model shared by the core ports.

use std::{error::Error, fmt};

/// Result type returned by soaprs ports.
pub type SoapResult<T> = Result<T, SoapError>;

/// Stable category of a [`SoapError`].
///
/// Applications and transports should branch on this value instead of parsing
/// an error message or depending on an infrastructure-specific source type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SoapErrorKind {
    /// A requested resource does not exist.
    NotFound,
    /// Input or domain validation failed.
    Validation,
    /// The requested change conflicts with existing state.
    Conflict,
    /// Authentication is missing or invalid.
    Unauthorized,
    /// The authenticated actor is not allowed to perform an operation.
    Forbidden,
    /// A domain invariant or operation failed.
    Domain,
    /// A requested capability is not supported by an adapter.
    Unsupported,
    /// An infrastructure operation exceeded its deadline.
    Timeout,
    /// An infrastructure dependency is temporarily unavailable.
    Unavailable,
    /// An infrastructure component failed for another reason.
    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,
        }
    }

    /// Indicates whether this category should normally be reported to
    /// monitoring.
    pub const fn is_reportable(self) -> bool {
        matches!(
            self,
            Self::Timeout | Self::Unavailable | Self::Infrastructure
        )
    }
}

/// Whether the underlying failure condition is expected to be short-lived.
///
/// A transient error does not by itself make retrying an operation safe. The
/// caller must also account for idempotency and whether the operation may have
/// completed before the error was observed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorTransience {
    /// Repeating the same operation is not expected to remove the failure.
    Permanent,
    /// The failure condition may disappear without changing the request.
    Transient,
    /// The adapter cannot reliably classify the failure condition.
    Unknown,
}

/// Opaque identifier used to correlate a returned error with diagnostics.
///
/// Generation belongs to an application boundary or observability adapter, so
/// this type does not require UUID or tracing dependencies in `soaprs-core`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiagnosticId(String);

impl DiagnosticId {
    /// Wraps an identifier generated by an application boundary.
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// Returns the identifier as text.
    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)
    }
}

/// Stable application error with optional technical diagnostics.
///
/// `message` is safe application-facing context. Infrastructure details stay
/// in the standard [`Error::source`] chain and must not be exposed directly in
/// transport responses.
#[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 {
    /// Creates an error with the category's default transience.
    pub fn new(kind: SoapErrorKind, message: impl Into<String>) -> Self {
        Self {
            kind,
            message: message.into(),
            transience: kind.default_transience(),
            diagnostic_id: None,
            source: None,
        }
    }

    /// Creates a missing-resource error.
    pub fn not_found(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::NotFound, message)
    }

    /// Creates an input or domain validation error.
    pub fn validation(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Validation, message)
    }

    /// Creates an error for an operation that conflicts with current state.
    pub fn conflict(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Conflict, message)
    }

    /// Creates an authentication error.
    pub fn unauthorized() -> Self {
        Self::new(SoapErrorKind::Unauthorized, "unauthorized")
    }

    /// Creates an authorization error.
    pub fn forbidden() -> Self {
        Self::new(SoapErrorKind::Forbidden, "forbidden")
    }

    /// Creates a domain-operation error.
    pub fn domain(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Domain, message)
    }

    /// Creates an unsupported-capability error.
    pub fn unsupported(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Unsupported, message)
    }

    /// Creates a transient timeout error.
    pub fn timeout(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Timeout, message)
    }

    /// Creates a transient dependency-unavailable error.
    pub fn unavailable(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Unavailable, message)
    }

    /// Creates an unclassified infrastructure error.
    pub fn infrastructure(message: impl Into<String>) -> Self {
        Self::new(SoapErrorKind::Infrastructure, message)
    }

    /// Attaches the original technical cause to this error.
    #[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
    }

    /// Overrides the adapter's classification of the failure condition.
    #[must_use]
    pub const fn with_transience(mut self, transience: ErrorTransience) -> Self {
        self.transience = transience;
        self
    }

    /// Attaches an identifier generated by an application boundary.
    #[must_use]
    pub fn with_diagnostic_id(mut self, diagnostic_id: impl Into<DiagnosticId>) -> Self {
        self.diagnostic_id = Some(diagnostic_id.into());
        self
    }

    /// Returns the stable error category.
    pub const fn kind(&self) -> SoapErrorKind {
        self.kind
    }

    /// Returns safe application-facing context.
    pub fn message(&self) -> &str {
        &self.message
    }

    /// Returns the failure-condition classification.
    pub const fn transience(&self) -> ErrorTransience {
        self.transience
    }

    /// Returns the optional diagnostics correlation identifier.
    pub fn diagnostic_id(&self) -> Option<&DiagnosticId> {
        self.diagnostic_id.as_ref()
    }

    /// Indicates whether the underlying failure condition may be short-lived.
    pub const fn is_transient(&self) -> bool {
        matches!(self.transience, ErrorTransience::Transient)
    }

    /// Indicates whether the error should normally be reported to monitoring.
    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());
    }
}