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