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::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
24pub 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct HttpErrorBody {
44 pub code: String,
46 pub message: String,
48 pub diagnostic_id: Option<String>,
50}
51
52#[derive(Clone)]
54pub struct HttpErrorResponse {
55 pub status: StatusCode,
57 pub body: HttpErrorBody,
59 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
74pub trait HttpErrorMapper: Send + Sync {
76 fn map_error(&self, error: &SoapError) -> HttpErrorResponse;
78}
79
80#[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
90pub 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}