Skip to main content

soaprs_http/
error_mapping.rs

1//! Default mapping from stable application errors to HTTP status codes.
2
3use http::{HeaderMap, StatusCode};
4use soaprs_core::{DiagnosticId, SoapError, SoapErrorKind};
5
6/// Returns the default HTTP status for a stable soaprs error category.
7///
8/// Framework adapters may override this mapping at their composition root.
9pub const fn default_error_status(error: &SoapError) -> StatusCode {
10    match error.kind() {
11        SoapErrorKind::NotFound => StatusCode::NOT_FOUND,
12        SoapErrorKind::Validation | SoapErrorKind::Domain => StatusCode::UNPROCESSABLE_ENTITY,
13        SoapErrorKind::Conflict => StatusCode::CONFLICT,
14        SoapErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
15        SoapErrorKind::Forbidden => StatusCode::FORBIDDEN,
16        SoapErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
17        SoapErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
18        SoapErrorKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
19        SoapErrorKind::Infrastructure => StatusCode::INTERNAL_SERVER_ERROR,
20    }
21}
22
23/// Stable machine-readable code for a soaprs error category.
24pub const fn default_error_code(kind: SoapErrorKind) -> &'static str {
25    match kind {
26        SoapErrorKind::NotFound => "not_found",
27        SoapErrorKind::Validation => "validation_error",
28        SoapErrorKind::Conflict => "conflict",
29        SoapErrorKind::Unauthorized => "unauthorized",
30        SoapErrorKind::Forbidden => "forbidden",
31        SoapErrorKind::Domain => "domain_error",
32        SoapErrorKind::Unsupported => "unsupported",
33        SoapErrorKind::Timeout => "timeout",
34        SoapErrorKind::Unavailable => "unavailable",
35        SoapErrorKind::Infrastructure => "internal_error",
36    }
37}
38
39/// Serializer-neutral safe error response body.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct HttpErrorBody {
42    /// Stable machine-readable error code.
43    pub code: String,
44    /// Safe application-facing message without technical source details.
45    pub message: String,
46    /// Optional identifier correlating the response with diagnostics.
47    pub diagnostic_id: Option<String>,
48}
49
50/// Complete serializer-neutral HTTP error response.
51#[derive(Clone)]
52pub struct HttpErrorResponse {
53    /// HTTP status selected for the error.
54    pub status: StatusCode,
55    /// Safe body serialized by a framework or contract adapter.
56    pub body: HttpErrorBody,
57    /// Optional headers such as `Retry-After` or `WWW-Authenticate`.
58    pub headers: HeaderMap,
59}
60
61impl std::fmt::Debug for HttpErrorResponse {
62    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        formatter
64            .debug_struct("HttpErrorResponse")
65            .field("status", &self.status)
66            .field("body", &self.body)
67            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
68            .finish_non_exhaustive()
69    }
70}
71
72/// Maps application errors into safe HTTP responses.
73pub trait HttpErrorMapper: Send + Sync {
74    /// Maps one error without exposing its technical source chain.
75    fn map_error(&self, error: &SoapError) -> HttpErrorResponse;
76}
77
78/// Default stable error mapper used by framework adapters unless overridden.
79#[derive(Debug, Clone, Copy, Default)]
80pub struct DefaultHttpErrorMapper;
81
82impl HttpErrorMapper for DefaultHttpErrorMapper {
83    fn map_error(&self, error: &SoapError) -> HttpErrorResponse {
84        default_error_response(error)
85    }
86}
87
88/// Builds the default safe error response.
89pub fn default_error_response(error: &SoapError) -> HttpErrorResponse {
90    HttpErrorResponse {
91        status: default_error_status(error),
92        body: HttpErrorBody {
93            code: default_error_code(error.kind()).to_owned(),
94            message: error.message().to_owned(),
95            diagnostic_id: error
96                .diagnostic_id()
97                .map(DiagnosticId::as_str)
98                .map(str::to_owned),
99        },
100        headers: HeaderMap::new(),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use http::{HeaderValue, StatusCode, header::WWW_AUTHENTICATE};
107    use soaprs_core::SoapError;
108
109    use super::{default_error_response, default_error_status};
110
111    #[test]
112    fn distinguishes_authentication_and_authorization() {
113        assert_eq!(
114            default_error_status(&SoapError::unauthorized()),
115            StatusCode::UNAUTHORIZED
116        );
117        assert_eq!(
118            default_error_status(&SoapError::forbidden()),
119            StatusCode::FORBIDDEN
120        );
121    }
122
123    #[test]
124    fn maps_stable_error_categories_to_default_statuses() {
125        let cases = [
126            (SoapError::not_found("user"), StatusCode::NOT_FOUND),
127            (
128                SoapError::validation("invalid email"),
129                StatusCode::UNPROCESSABLE_ENTITY,
130            ),
131            (
132                SoapError::domain("account is closed"),
133                StatusCode::UNPROCESSABLE_ENTITY,
134            ),
135            (SoapError::conflict("email exists"), StatusCode::CONFLICT),
136            (
137                SoapError::unsupported("full-text search"),
138                StatusCode::NOT_IMPLEMENTED,
139            ),
140            (
141                SoapError::timeout("database query"),
142                StatusCode::GATEWAY_TIMEOUT,
143            ),
144            (
145                SoapError::unavailable("database"),
146                StatusCode::SERVICE_UNAVAILABLE,
147            ),
148            (
149                SoapError::infrastructure("database operation"),
150                StatusCode::INTERNAL_SERVER_ERROR,
151            ),
152        ];
153
154        for (error, expected) in cases {
155            assert_eq!(default_error_status(&error), expected);
156        }
157    }
158
159    #[test]
160    fn safe_error_body_contains_stable_code_and_diagnostic_id() {
161        let error = SoapError::infrastructure("request failed")
162            .with_diagnostic_id("diagnostic-42")
163            .with_source(std::io::Error::other("secret driver detail"));
164        let response = default_error_response(&error);
165
166        assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
167        assert_eq!(response.body.code, "internal_error");
168        assert_eq!(response.body.message, "request failed");
169        assert_eq!(
170            response.body.diagnostic_id.as_deref(),
171            Some("diagnostic-42")
172        );
173        assert!(!response.body.message.contains("secret driver detail"));
174    }
175
176    #[test]
177    fn error_response_debug_output_redacts_header_values() {
178        let mut response = default_error_response(&SoapError::unauthorized());
179        response.headers.insert(
180            WWW_AUTHENTICATE,
181            HeaderValue::from_static("Bearer realm=\"private-realm\""),
182        );
183        let debug = format!("{response:?}");
184
185        assert!(debug.contains("www-authenticate"));
186        assert!(!debug.contains("private-realm"));
187    }
188}