1use std::{error::Error, fmt};
4
5pub type SoapResult<T> = Result<T, SoapError>;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SoapErrorKind {
14 NotFound,
16 Validation,
18 Conflict,
20 Unauthorized,
22 Forbidden,
24 Domain,
26 Unsupported,
28 Timeout,
30 Unavailable,
32 Infrastructure,
34}
35
36impl SoapErrorKind {
37 const fn default_transience(self) -> ErrorTransience {
38 match self {
39 Self::Timeout | Self::Unavailable => ErrorTransience::Transient,
40 Self::Infrastructure => ErrorTransience::Unknown,
41 Self::NotFound
42 | Self::Validation
43 | Self::Conflict
44 | Self::Unauthorized
45 | Self::Forbidden
46 | Self::Domain
47 | Self::Unsupported => ErrorTransience::Permanent,
48 }
49 }
50
51 pub const fn is_reportable(self) -> bool {
54 matches!(
55 self,
56 Self::Timeout | Self::Unavailable | Self::Infrastructure
57 )
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum ErrorTransience {
68 Permanent,
70 Transient,
72 Unknown,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub struct DiagnosticId(String);
82
83impl DiagnosticId {
84 pub fn new(value: impl Into<String>) -> Self {
86 Self(value.into())
87 }
88
89 pub fn as_str(&self) -> &str {
91 &self.0
92 }
93}
94
95impl fmt::Display for DiagnosticId {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(&self.0)
98 }
99}
100
101impl From<String> for DiagnosticId {
102 fn from(value: String) -> Self {
103 Self::new(value)
104 }
105}
106
107impl From<&str> for DiagnosticId {
108 fn from(value: &str) -> Self {
109 Self::new(value)
110 }
111}
112
113#[derive(Debug)]
119pub struct SoapError {
120 kind: SoapErrorKind,
121 message: String,
122 transience: ErrorTransience,
123 diagnostic_id: Option<DiagnosticId>,
124 source: Option<Box<dyn Error + Send + Sync + 'static>>,
125}
126
127impl SoapError {
128 pub fn new(kind: SoapErrorKind, message: impl Into<String>) -> Self {
130 Self {
131 kind,
132 message: message.into(),
133 transience: kind.default_transience(),
134 diagnostic_id: None,
135 source: None,
136 }
137 }
138
139 pub fn not_found(message: impl Into<String>) -> Self {
141 Self::new(SoapErrorKind::NotFound, message)
142 }
143
144 pub fn validation(message: impl Into<String>) -> Self {
146 Self::new(SoapErrorKind::Validation, message)
147 }
148
149 pub fn conflict(message: impl Into<String>) -> Self {
151 Self::new(SoapErrorKind::Conflict, message)
152 }
153
154 pub fn unauthorized() -> Self {
156 Self::new(SoapErrorKind::Unauthorized, "unauthorized")
157 }
158
159 pub fn forbidden() -> Self {
161 Self::new(SoapErrorKind::Forbidden, "forbidden")
162 }
163
164 pub fn domain(message: impl Into<String>) -> Self {
166 Self::new(SoapErrorKind::Domain, message)
167 }
168
169 pub fn unsupported(message: impl Into<String>) -> Self {
171 Self::new(SoapErrorKind::Unsupported, message)
172 }
173
174 pub fn timeout(message: impl Into<String>) -> Self {
176 Self::new(SoapErrorKind::Timeout, message)
177 }
178
179 pub fn unavailable(message: impl Into<String>) -> Self {
181 Self::new(SoapErrorKind::Unavailable, message)
182 }
183
184 pub fn infrastructure(message: impl Into<String>) -> Self {
186 Self::new(SoapErrorKind::Infrastructure, message)
187 }
188
189 #[must_use]
191 pub fn with_source<E>(mut self, source: E) -> Self
192 where
193 E: Error + Send + Sync + 'static,
194 {
195 self.source = Some(Box::new(source));
196 self
197 }
198
199 #[must_use]
201 pub const fn with_transience(mut self, transience: ErrorTransience) -> Self {
202 self.transience = transience;
203 self
204 }
205
206 #[must_use]
208 pub fn with_diagnostic_id(mut self, diagnostic_id: impl Into<DiagnosticId>) -> Self {
209 self.diagnostic_id = Some(diagnostic_id.into());
210 self
211 }
212
213 pub const fn kind(&self) -> SoapErrorKind {
215 self.kind
216 }
217
218 pub fn message(&self) -> &str {
220 &self.message
221 }
222
223 pub const fn transience(&self) -> ErrorTransience {
225 self.transience
226 }
227
228 pub fn diagnostic_id(&self) -> Option<&DiagnosticId> {
230 self.diagnostic_id.as_ref()
231 }
232
233 pub const fn is_transient(&self) -> bool {
235 matches!(self.transience, ErrorTransience::Transient)
236 }
237
238 pub const fn is_reportable(&self) -> bool {
240 self.kind.is_reportable()
241 }
242}
243
244impl fmt::Display for SoapError {
245 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246 match self.kind {
247 SoapErrorKind::NotFound => write!(formatter, "not found: {}", self.message),
248 SoapErrorKind::Validation => {
249 write!(formatter, "validation failed: {}", self.message)
250 }
251 SoapErrorKind::Conflict => write!(formatter, "conflict: {}", self.message),
252 SoapErrorKind::Unauthorized | SoapErrorKind::Forbidden | SoapErrorKind::Domain => {
253 formatter.write_str(&self.message)
254 }
255 SoapErrorKind::Unsupported => write!(formatter, "unsupported: {}", self.message),
256 SoapErrorKind::Timeout => write!(formatter, "timeout: {}", self.message),
257 SoapErrorKind::Unavailable => write!(formatter, "unavailable: {}", self.message),
258 SoapErrorKind::Infrastructure => {
259 write!(formatter, "infrastructure error: {}", self.message)
260 }
261 }
262 }
263}
264
265impl Error for SoapError {
266 fn source(&self) -> Option<&(dyn Error + 'static)> {
267 self.source
268 .as_deref()
269 .map(|source| source as &(dyn Error + 'static))
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use std::{error::Error, io};
276
277 use super::{DiagnosticId, ErrorTransience, SoapError, SoapErrorKind};
278
279 #[test]
280 fn stable_kinds_have_expected_reporting_and_transience_defaults() {
281 let validation = SoapError::validation("name is empty");
282 assert_eq!(validation.kind(), SoapErrorKind::Validation);
283 assert_eq!(validation.transience(), ErrorTransience::Permanent);
284 assert!(!validation.is_reportable());
285 assert!(!validation.is_transient());
286
287 let timeout = SoapError::timeout("database query");
288 assert_eq!(timeout.kind(), SoapErrorKind::Timeout);
289 assert_eq!(timeout.transience(), ErrorTransience::Transient);
290 assert!(timeout.is_reportable());
291 assert!(timeout.is_transient());
292
293 let infrastructure = SoapError::infrastructure("database operation failed");
294 assert_eq!(infrastructure.transience(), ErrorTransience::Unknown);
295 assert!(infrastructure.is_reportable());
296 }
297
298 #[test]
299 fn original_source_is_preserved_but_not_exposed_by_display() {
300 let error = SoapError::unavailable("user database is unavailable").with_source(
301 io::Error::new(io::ErrorKind::ConnectionRefused, "secret driver detail"),
302 );
303
304 let Some(source) = error.source() else {
305 panic!("source must be preserved");
306 };
307 assert_eq!(source.to_string(), "secret driver detail");
308 assert_eq!(
309 error.to_string(),
310 "unavailable: user database is unavailable"
311 );
312 assert!(!error.to_string().contains("secret driver detail"));
313 }
314
315 #[test]
316 fn mapped_business_error_can_keep_technical_source() {
317 let error = SoapError::conflict("email already exists").with_source(io::Error::new(
318 io::ErrorKind::AlreadyExists,
319 "unique constraint users_email_key",
320 ));
321
322 assert_eq!(error.kind(), SoapErrorKind::Conflict);
323 assert!(!error.is_reportable());
324 assert_eq!(
325 error.source().map(ToString::to_string),
326 Some("unique constraint users_email_key".into())
327 );
328 }
329
330 #[test]
331 fn diagnostic_identifier_is_opaque_and_optional() {
332 let error = SoapError::infrastructure("storage failed")
333 .with_diagnostic_id(DiagnosticId::new("0195d6b4-test"));
334
335 assert_eq!(
336 error.diagnostic_id().map(DiagnosticId::as_str),
337 Some("0195d6b4-test")
338 );
339 }
340
341 #[test]
342 fn adapter_can_override_transience_without_changing_kind() {
343 let error = SoapError::infrastructure("serialization failure")
344 .with_transience(ErrorTransience::Transient);
345
346 assert_eq!(error.kind(), SoapErrorKind::Infrastructure);
347 assert!(error.is_transient());
348 }
349}