soaprs-http 0.3.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Default mapping from stable application errors to HTTP status codes.

use http::{HeaderMap, StatusCode};
use soaprs_core::{DiagnosticId, SoapError, SoapErrorKind};

/// Returns the default HTTP status for a stable soaprs error category.
///
/// Framework adapters may override this mapping at their composition root.
pub const fn default_error_status(error: &SoapError) -> StatusCode {
    match error.kind() {
        SoapErrorKind::NotFound => StatusCode::NOT_FOUND,
        SoapErrorKind::Validation | SoapErrorKind::Domain => StatusCode::UNPROCESSABLE_ENTITY,
        SoapErrorKind::Conflict => StatusCode::CONFLICT,
        SoapErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
        SoapErrorKind::Forbidden => StatusCode::FORBIDDEN,
        SoapErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
        SoapErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
        SoapErrorKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
        SoapErrorKind::Infrastructure => StatusCode::INTERNAL_SERVER_ERROR,
    }
}

/// Stable machine-readable code for a soaprs error category.
pub const fn default_error_code(kind: SoapErrorKind) -> &'static str {
    match kind {
        SoapErrorKind::NotFound => "not_found",
        SoapErrorKind::Validation => "validation_error",
        SoapErrorKind::Conflict => "conflict",
        SoapErrorKind::Unauthorized => "unauthorized",
        SoapErrorKind::Forbidden => "forbidden",
        SoapErrorKind::Domain => "domain_error",
        SoapErrorKind::Unsupported => "unsupported",
        SoapErrorKind::Timeout => "timeout",
        SoapErrorKind::Unavailable => "unavailable",
        SoapErrorKind::Infrastructure => "internal_error",
    }
}

/// Serializer-neutral safe error response body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpErrorBody {
    /// Stable machine-readable error code.
    pub code: String,
    /// Safe application-facing message without technical source details.
    pub message: String,
    /// Optional identifier correlating the response with diagnostics.
    pub diagnostic_id: Option<String>,
}

/// Complete serializer-neutral HTTP error response.
#[derive(Clone)]
pub struct HttpErrorResponse {
    /// HTTP status selected for the error.
    pub status: StatusCode,
    /// Safe body serialized by a framework or contract adapter.
    pub body: HttpErrorBody,
    /// Optional headers such as `Retry-After` or `WWW-Authenticate`.
    pub headers: HeaderMap,
}

impl std::fmt::Debug for HttpErrorResponse {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("HttpErrorResponse")
            .field("status", &self.status)
            .field("body", &self.body)
            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
            .finish_non_exhaustive()
    }
}

/// Maps application errors into safe HTTP responses.
pub trait HttpErrorMapper: Send + Sync {
    /// Maps one error without exposing its technical source chain.
    fn map_error(&self, error: &SoapError) -> HttpErrorResponse;
}

/// Default stable error mapper used by framework adapters unless overridden.
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultHttpErrorMapper;

impl HttpErrorMapper for DefaultHttpErrorMapper {
    fn map_error(&self, error: &SoapError) -> HttpErrorResponse {
        default_error_response(error)
    }
}

/// Builds the default safe error response.
pub fn default_error_response(error: &SoapError) -> HttpErrorResponse {
    HttpErrorResponse {
        status: default_error_status(error),
        body: HttpErrorBody {
            code: default_error_code(error.kind()).to_owned(),
            message: error.message().to_owned(),
            diagnostic_id: error
                .diagnostic_id()
                .map(DiagnosticId::as_str)
                .map(str::to_owned),
        },
        headers: HeaderMap::new(),
    }
}

#[cfg(test)]
mod tests {
    use http::{HeaderValue, StatusCode, header::WWW_AUTHENTICATE};
    use soaprs_core::SoapError;

    use super::{default_error_response, default_error_status};

    #[test]
    fn distinguishes_authentication_and_authorization() {
        assert_eq!(
            default_error_status(&SoapError::unauthorized()),
            StatusCode::UNAUTHORIZED
        );
        assert_eq!(
            default_error_status(&SoapError::forbidden()),
            StatusCode::FORBIDDEN
        );
    }

    #[test]
    fn maps_stable_error_categories_to_default_statuses() {
        let cases = [
            (SoapError::not_found("user"), StatusCode::NOT_FOUND),
            (
                SoapError::validation("invalid email"),
                StatusCode::UNPROCESSABLE_ENTITY,
            ),
            (
                SoapError::domain("account is closed"),
                StatusCode::UNPROCESSABLE_ENTITY,
            ),
            (SoapError::conflict("email exists"), StatusCode::CONFLICT),
            (
                SoapError::unsupported("full-text search"),
                StatusCode::NOT_IMPLEMENTED,
            ),
            (
                SoapError::timeout("database query"),
                StatusCode::GATEWAY_TIMEOUT,
            ),
            (
                SoapError::unavailable("database"),
                StatusCode::SERVICE_UNAVAILABLE,
            ),
            (
                SoapError::infrastructure("database operation"),
                StatusCode::INTERNAL_SERVER_ERROR,
            ),
        ];

        for (error, expected) in cases {
            assert_eq!(default_error_status(&error), expected);
        }
    }

    #[test]
    fn safe_error_body_contains_stable_code_and_diagnostic_id() {
        let error = SoapError::infrastructure("request failed")
            .with_diagnostic_id("diagnostic-42")
            .with_source(std::io::Error::other("secret driver detail"));
        let response = default_error_response(&error);

        assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
        assert_eq!(response.body.code, "internal_error");
        assert_eq!(response.body.message, "request failed");
        assert_eq!(
            response.body.diagnostic_id.as_deref(),
            Some("diagnostic-42")
        );
        assert!(!response.body.message.contains("secret driver detail"));
    }

    #[test]
    fn error_response_debug_output_redacts_header_values() {
        let mut response = default_error_response(&SoapError::unauthorized());
        response.headers.insert(
            WWW_AUTHENTICATE,
            HeaderValue::from_static("Bearer realm=\"private-realm\""),
        );
        let debug = format!("{response:?}");

        assert!(debug.contains("www-authenticate"));
        assert!(!debug.contains("private-realm"));
    }
}