soaprs_http/
error_mapping.rs1use http::{HeaderMap, StatusCode};
4use soaprs_core::{DiagnosticId, SoapError, SoapErrorKind};
5
6pub 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
23pub 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct HttpErrorBody {
42 pub code: String,
44 pub message: String,
46 pub diagnostic_id: Option<String>,
48}
49
50#[derive(Clone)]
52pub struct HttpErrorResponse {
53 pub status: StatusCode,
55 pub body: HttpErrorBody,
57 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
72pub trait HttpErrorMapper: Send + Sync {
74 fn map_error(&self, error: &SoapError) -> HttpErrorResponse;
76}
77
78#[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
88pub 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}