Skip to main content

haste_fhir_operation_error/
lib.rs

1//! The error type of the Haste Health Rust stack: a FHIR `OperationOutcome`
2//! for the client, plus what a log needs to trace the failure to its origin.
3
4use std::{
5    backtrace::Backtrace,
6    borrow::Cow,
7    error::Error,
8    fmt::{self, Debug, Display},
9    panic::Location,
10    sync::atomic::{AtomicBool, Ordering},
11};
12
13use haste_fhir_model::r4::generated::{
14    resources::{OperationOutcome, OperationOutcomeIssue},
15    terminology::{BoundCode, IssueSeverity, IssueType},
16    types::FHIRString,
17};
18
19#[cfg(feature = "derive")]
20pub mod derive;
21
22#[cfg(feature = "axum")]
23pub mod axum;
24
25/// The error an [`OperationOutcomeError`] wraps.
26pub type Source = Box<dyn Error + Send + Sync + 'static>;
27
28/// An HTTP status that takes precedence over the one derived from the first issue's code.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum StatusOverride {
31    /// 422: the request is well formed but cannot be processed as it is.
32    UnprocessableEntity,
33}
34
35/// One [`OperationOutcomeError::context`] call: what the caller was doing and
36/// where it said so.
37pub struct Hop {
38    message: Cow<'static, str>,
39    location: &'static Location<'static>,
40}
41
42impl Hop {
43    #[must_use]
44    pub fn message(&self) -> &str {
45        &self.message
46    }
47
48    #[must_use]
49    pub fn location(&self) -> &'static Location<'static> {
50        self.location
51    }
52}
53
54impl Debug for Hop {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{:?} at {}", self.message, self.location)
57    }
58}
59
60struct Inner {
61    outcome: OperationOutcome,
62    source: Option<Source>,
63    status_override: Option<StatusOverride>,
64    /// Where the error was built, or where a `?` turned a foreign error into one.
65    origin: &'static Location<'static>,
66    /// `context` calls, innermost first. Empty until the first call, so it
67    /// costs nothing to carry.
68    trail: Vec<Hop>,
69    /// Captured for server errors once [`OperationOutcomeError::capture_backtraces`]
70    /// has been turned on.
71    backtrace: Option<Backtrace>,
72}
73
74/// A failure as a FHIR `OperationOutcome`, with the HTTP status it answers
75/// and a record of where it came from.
76///
77/// Boxed so that a `Result<T, OperationOutcomeError>` is no larger than `T`
78/// needs: the Ok path pays nothing for the error's bookkeeping.
79pub struct OperationOutcomeError(Box<Inner>);
80
81static CAPTURE_BACKTRACES: AtomicBool = AtomicBool::new(false);
82
83fn create_operation_outcome(
84    severity: BoundCode<IssueSeverity>,
85    code: BoundCode<IssueType>,
86    diagnostic: String,
87) -> OperationOutcome {
88    OperationOutcome {
89        issue: vec![OperationOutcomeIssue {
90            severity,
91            code,
92            diagnostics: Some(Box::new(FHIRString {
93                value: Some(diagnostic),
94                ..Default::default()
95            })),
96            ..Default::default()
97        }],
98        ..Default::default()
99    }
100}
101
102/// The HTTP status an issue code answers with. 5xx marks a failure of the
103/// server rather than of the request.
104fn status_for_code(code: &BoundCode<IssueType>) -> u16 {
105    match code.as_str() {
106        // `invalid` and its children in the IssueType hierarchy.
107        Some("invalid" | "structure" | "required" | "value" | "invariant") => 400,
108        // The caller is not (or no longer) authenticated.
109        Some("login" | "unknown" | "expired") => 401,
110        Some("forbidden" | "security" | "suppressed") => 403,
111        Some("not-found") => 404,
112        // An operation that affects state invoked with GET.
113        Some("business-rule") => 405,
114        Some("conflict") => 409,
115        Some("throttled") => 429,
116        Some("not-supported") => 501,
117        _ => 500,
118    }
119}
120
121impl OperationOutcomeError {
122    /// Capture a backtrace for every error that answers 5xx from now on.
123    /// Off by default: a backtrace costs microseconds to capture and a
124    /// rejected request does not need one.
125    pub fn capture_backtraces(enabled: bool) {
126        CAPTURE_BACKTRACES.store(enabled, Ordering::Relaxed);
127    }
128
129    fn build(
130        source: Option<Source>,
131        outcome: OperationOutcome,
132        status_override: Option<StatusOverride>,
133        origin: &'static Location<'static>,
134    ) -> Self {
135        let mut inner = Box::new(Inner {
136            outcome,
137            source,
138            status_override,
139            origin,
140            trail: Vec::new(),
141            backtrace: None,
142        });
143        if CAPTURE_BACKTRACES.load(Ordering::Relaxed) && inner.http_status() >= 500 {
144            inner.backtrace = Some(Backtrace::force_capture());
145        }
146        OperationOutcomeError(inner)
147    }
148
149    #[must_use]
150    #[track_caller]
151    pub fn new(source: Option<Source>, outcome: OperationOutcome) -> Self {
152        Self::build(source, outcome, None, Location::caller())
153    }
154
155    /// An error that answers 422 Unprocessable Entity whatever its issue codes:
156    /// the request is well formed but cannot be processed as it is, such as a
157    /// resource that violates an applicable profile.
158    #[must_use]
159    #[track_caller]
160    pub fn unprocessable(outcome: OperationOutcome) -> Self {
161        Self::build(
162            None,
163            outcome,
164            Some(StatusOverride::UnprocessableEntity),
165            Location::caller(),
166        )
167    }
168
169    #[must_use]
170    #[track_caller]
171    pub fn fatal(code: BoundCode<IssueType>, diagnostic: String) -> Self {
172        Self::build(
173            None,
174            create_operation_outcome(IssueSeverity::fatal(), code, diagnostic),
175            None,
176            Location::caller(),
177        )
178    }
179
180    #[must_use]
181    #[track_caller]
182    pub fn error(code: BoundCode<IssueType>, diagnostic: String) -> Self {
183        Self::build(
184            None,
185            create_operation_outcome(IssueSeverity::error(), code, diagnostic),
186            None,
187            Location::caller(),
188        )
189    }
190
191    #[must_use]
192    #[track_caller]
193    pub fn warning(code: BoundCode<IssueType>, diagnostic: String) -> Self {
194        Self::build(
195            None,
196            create_operation_outcome(IssueSeverity::warning(), code, diagnostic),
197            None,
198            Location::caller(),
199        )
200    }
201
202    #[must_use]
203    #[track_caller]
204    pub fn information(code: BoundCode<IssueType>, diagnostic: String) -> Self {
205        Self::build(
206            None,
207            create_operation_outcome(IssueSeverity::information(), code, diagnostic),
208            None,
209            Location::caller(),
210        )
211    }
212
213    /// Attach the error this one wraps. Prefer this over formatting the cause
214    /// into the diagnostic: the client keeps the message you wrote and the
215    /// log gets the whole chain.
216    #[must_use]
217    pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
218        self.0.source = Some(Box::new(source));
219        self
220    }
221
222    /// Record what the caller was doing when the error passed through. Shows
223    /// up in the log next to the origin; the client never sees it.
224    #[must_use]
225    #[track_caller]
226    pub fn context(mut self, message: impl Into<Cow<'static, str>>) -> Self {
227        self.0.trail.push(Hop {
228            message: message.into(),
229            location: Location::caller(),
230        });
231        self
232    }
233
234    #[must_use]
235    pub fn outcome(&self) -> &OperationOutcome {
236        &self.0.outcome
237    }
238
239    pub fn push_issue(&mut self, issue: OperationOutcomeIssue) {
240        self.0.outcome.issue.push(issue);
241    }
242
243    /// Where the error was built, or where a `?` turned a foreign error into one.
244    #[must_use]
245    pub fn origin(&self) -> &'static Location<'static> {
246        self.0.origin
247    }
248
249    /// The [`context`](Self::context) calls the error passed through, innermost first.
250    #[must_use]
251    pub fn trail(&self) -> &[Hop] {
252        &self.0.trail
253    }
254
255    #[must_use]
256    pub fn backtrace(&self) -> Option<&Backtrace> {
257        self.0.backtrace.as_ref()
258    }
259
260    /// The attached source error and everything it in turn was caused by.
261    pub fn causes(&self) -> impl Iterator<Item = &(dyn Error + 'static)> {
262        std::iter::successors(self.source(), |&error| error.source())
263    }
264
265    /// The HTTP status the error answers with: an override if set, otherwise
266    /// the status the first issue's code maps to.
267    #[must_use]
268    pub fn http_status(&self) -> u16 {
269        self.0.http_status()
270    }
271
272    /// Whether the status is 5xx: the server failed, not the request.
273    #[must_use]
274    pub fn is_server_error(&self) -> bool {
275        self.http_status() >= 500
276    }
277}
278
279impl Inner {
280    fn http_status(&self) -> u16 {
281        match self.status_override {
282            Some(StatusOverride::UnprocessableEntity) => 422,
283            None => self
284                .outcome
285                .issue
286                .first()
287                .map_or(500, |issue| status_for_code(&issue.code)),
288        }
289    }
290}
291
292/// [`OperationOutcomeError::context`] on a `Result`, applied only on the error path.
293pub trait ResultExt {
294    #[must_use]
295    fn context(self, message: impl Into<Cow<'static, str>>) -> Self;
296}
297
298impl<T> ResultExt for Result<T, OperationOutcomeError> {
299    #[track_caller]
300    fn context(self, message: impl Into<Cow<'static, str>>) -> Self {
301        match self {
302            Ok(value) => Ok(value),
303            Err(error) => Err(error.context(message)),
304        }
305    }
306}
307
308fn get_issue_diagnostics(issue: &OperationOutcomeIssue) -> Option<&str> {
309    issue.diagnostics.as_ref().and_then(|d| d.value.as_deref())
310}
311
312impl Display for OperationOutcomeError {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        write!(
315            f,
316            "Operation Error: '{}'",
317            self.0
318                .outcome
319                .issue
320                .iter()
321                .filter_map(get_issue_diagnostics)
322                .collect::<Vec<_>>()
323                .join(", ")
324        )
325    }
326}
327
328/// `severity code: diagnostics`, instead of the issue's many empty fields.
329struct IssueSummary<'a>(&'a OperationOutcomeIssue);
330
331impl Debug for IssueSummary<'_> {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        write!(
334            f,
335            "{} {}: {:?}",
336            self.0.severity.as_str().unwrap_or("?"),
337            self.0.code.as_str().unwrap_or("?"),
338            get_issue_diagnostics(self.0).unwrap_or("")
339        )
340    }
341}
342
343struct Issues<'a>(&'a [OperationOutcomeIssue]);
344
345impl Debug for Issues<'_> {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        f.debug_list()
348            .entries(self.0.iter().map(IssueSummary))
349            .finish()
350    }
351}
352
353struct Cause<'a>(&'a (dyn Error + 'static));
354
355impl Debug for Cause<'_> {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        Display::fmt(self.0, f)
358    }
359}
360
361/// The source chain, outermost first.
362struct Causes<'a>(&'a OperationOutcomeError);
363
364impl Debug for Causes<'_> {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        f.debug_list().entries(self.0.causes().map(Cause)).finish()
367    }
368}
369
370impl Debug for OperationOutcomeError {
371    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
372        let inner = &self.0;
373        let mut s = f.debug_struct("OperationOutcomeError");
374        s.field("status", &inner.http_status())
375            .field("issues", &Issues(&inner.outcome.issue))
376            .field("origin", &format_args!("{}", inner.origin));
377        if !inner.trail.is_empty() {
378            s.field("trail", &inner.trail);
379        }
380        if inner.source.is_some() {
381            s.field("causes", &Causes(self));
382        }
383        if let Some(backtrace) = &inner.backtrace {
384            s.field("backtrace", &format_args!("{backtrace}"));
385        }
386        s.finish()
387    }
388}
389
390impl Error for OperationOutcomeError {
391    fn source(&self) -> Option<&(dyn Error + 'static)> {
392        self.0
393            .source
394            .as_deref()
395            .map(|source| source as &(dyn Error + 'static))
396    }
397}