Skip to main content

ferrobridge_validator/
report.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The outcome of one validation: the issues, and every constraint the
5//! validator met and could not evaluate, convertible into an R4
6//! `OperationOutcome`.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use fhir_types::r4::codeable_concept::CodeableConcept;
12use fhir_types::r4::coding::Coding;
13use fhir_types::r4::operation_outcome::OperationOutcome;
14use fhir_types::r4::operation_outcome::OperationOutcomeIssue;
15use fhir_types::r4::primitives;
16
17use crate::issue::Issue;
18use crate::issue::IssueType;
19use crate::issue::MessageId;
20use crate::issue::Rule;
21use crate::issue::Severity;
22
23/// The system of the `details.coding` that carries a message id.
24///
25/// No specification governs this: our own design. R4 defines no validator
26/// messages, so the ids live in a system of their own.
27pub const MESSAGE_SYSTEM: &str = "https://ferrobridge.eu/fhir/CodeSystem/validator-message";
28
29/// A constraint of a `StructureDefinition` the validator met on an element of
30/// the instance.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub enum Constraint {
33    /// The slicing an element definition declares
34    /// (<https://hl7.org/fhir/R4/profiling.html#slicing>).
35    Slicing,
36    /// One discriminator of a slicing
37    /// (<https://hl7.org/fhir/R4/profiling.html#discriminator>).
38    Discriminator {
39        /// The discriminator type: `value`, `exists`, `pattern`, `type` or
40        /// `profile`.
41        kind: String,
42        /// The discriminator path.
43        path: String,
44    },
45    /// A `value` or `pattern` discriminator whose slice is told apart by a
46    /// required binding.
47    DiscriminatorValueSet {
48        /// The discriminator path.
49        path: String,
50        /// The value set the binding names.
51        value_set: String,
52    },
53    /// An invariant (<https://hl7.org/fhir/R4/conformance-rules.html#constraints>).
54    Invariant {
55        /// The constraint key.
56        key: String,
57        /// The constraint's own severity, `error` or `warning`.
58        severity: String,
59    },
60    /// A terminology binding (<https://hl7.org/fhir/R4/terminologies.html#strength>).
61    Binding {
62        /// The binding strength.
63        strength: String,
64        /// The value set, when the binding names one.
65        value_set: Option<String>,
66    },
67    /// The profiles a type of the element names (`type.profile`).
68    TypeProfile {
69        /// The profile canonicals.
70        profiles: Vec<String>,
71    },
72    /// The profiles a reference's target SHALL conform to
73    /// (`type.targetProfile`).
74    TargetProfile {
75        /// The profile canonicals.
76        profiles: Vec<String>,
77    },
78    /// A profile the instance claims in `meta.profile`.
79    ClaimedProfile {
80        /// The profile canonical.
81        url: String,
82    },
83    /// The contexts an extension may be used in
84    /// (`StructureDefinition.context`).
85    ExtensionContext,
86}
87
88/// Why a constraint was not evaluated.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub enum Reason {
91    /// The discriminator path is outside the simple FHIRPath subset
92    /// (<https://hl7.org/fhir/R4/fhirpath.html#simple>).
93    DiscriminatorPath,
94    /// The discriminator type is none of the five R4 defines.
95    DiscriminatorType,
96    /// The slice sets no value, pattern, required binding, cardinality, type
97    /// or profile at the discriminator path to tell it apart by.
98    NoDiscriminatorValue,
99    /// The value set of the binding cannot be enumerated from the package
100    /// set.
101    ValueSetNotEnumerated,
102    /// A reference the discriminator path resolves names no resource the
103    /// reference points into.
104    UnresolvedReference,
105    /// A reference the discriminator path resolves points outside the
106    /// resources the validator searches.
107    ReferenceOutOfScope,
108    /// The profile is not in the package set.
109    ProfileNotFound,
110    /// The profile declares a `@default` slice under rules other than
111    /// `closed`, which R4 does not allow, so the default slice is not used.
112    DefaultSliceNotClosed,
113    /// The validator does not evaluate invariants.
114    Invariants,
115    /// The validator does not check terminology bindings.
116    Bindings,
117    /// The validator does not validate a reference's target against its
118    /// target profiles, or a resource against a profile it claims.
119    Profiles,
120    /// The validator does not check where an extension is used.
121    ExtensionContext,
122}
123
124impl fmt::Display for Reason {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.write_str(match self {
127            Self::DiscriminatorPath => {
128                "the discriminator path is outside the FHIRPath subset discriminators use"
129            }
130            Self::DiscriminatorType => "the discriminator type is none R4 defines",
131            Self::NoDiscriminatorValue => {
132                "the slice sets nothing at the discriminator path to tell it apart by"
133            }
134            Self::ValueSetNotEnumerated => {
135                "the value set cannot be enumerated from the package set"
136            }
137            Self::UnresolvedReference => "a reference the discriminator resolves does not resolve",
138            Self::ReferenceOutOfScope => {
139                "a reference the discriminator resolves points outside the resources searched"
140            }
141            Self::ProfileNotFound => "the profile is not in the package set",
142            Self::DefaultSliceNotClosed => {
143                "the @default slice is not used, because the slicing rules are not closed"
144            }
145            Self::Invariants => "invariants are not evaluated",
146            Self::Bindings => "terminology bindings are not checked",
147            Self::Profiles => "target profiles and claimed profiles are not validated",
148            Self::ExtensionContext => "extension contexts are not checked",
149        })
150    }
151}
152
153/// A constraint the validator met and did not evaluate, never a silent pass.
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
155pub struct Unevaluated {
156    /// The constraint.
157    pub constraint: Constraint,
158    /// The canonical of the `StructureDefinition` that declares it.
159    pub structure: String,
160    /// The element id it is declared on.
161    pub element: String,
162    /// The FHIRPath location of the first occurrence it applied to.
163    pub location: String,
164    /// Why it was not evaluated.
165    pub reason: Reason,
166}
167
168/// The outcome of one validation.
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170pub struct Report {
171    issues: Vec<Issue>,
172    unevaluated: Vec<Unevaluated>,
173}
174
175impl Report {
176    /// Returns the issues in the order they were found.
177    #[must_use]
178    pub fn issues(&self) -> &[Issue] {
179        &self.issues
180    }
181
182    /// Returns every constraint not evaluated, once per declaring
183    /// `StructureDefinition`, element and constraint, in that order.
184    #[must_use]
185    pub fn unevaluated(&self) -> &[Unevaluated] {
186        &self.unevaluated
187    }
188
189    /// Answers whether no issue is an error or fatal.
190    ///
191    /// An unevaluated constraint does not make the instance invalid; it makes
192    /// the answer incomplete, which [`Report::unevaluated`] states.
193    #[must_use]
194    pub fn is_valid(&self) -> bool {
195        !self
196            .issues
197            .iter()
198            .any(|issue| matches!(issue.severity, Severity::Fatal | Severity::Error))
199    }
200
201    /// Returns the issues carrying `id`.
202    pub fn with_message(&self, id: MessageId) -> impl Iterator<Item = &Issue> {
203        self.issues
204            .iter()
205            .filter(move |issue| issue.rule == Rule::Message(id))
206    }
207}
208
209/// Collects issues and unevaluated constraints during one validation.
210#[derive(Debug, Default)]
211pub(crate) struct Collector {
212    issues: Vec<Issue>,
213    unevaluated: BTreeMap<(String, String, Constraint), (String, Reason)>,
214}
215
216impl Collector {
217    /// Records an issue against one of our message ids.
218    pub(crate) fn issue(
219        &mut self,
220        severity: Severity,
221        code: IssueType,
222        id: MessageId,
223        expression: &str,
224        message: String,
225    ) {
226        self.issues.push(Issue {
227            severity,
228            code,
229            expression: expression.to_owned(),
230            rule: Rule::Message(id),
231            message,
232        });
233    }
234
235    /// Records a constraint not evaluated, keeping its first location.
236    pub(crate) fn unevaluated(
237        &mut self,
238        structure: &str,
239        element: &str,
240        constraint: Constraint,
241        location: &str,
242        reason: Reason,
243    ) {
244        self.unevaluated
245            .entry((structure.to_owned(), element.to_owned(), constraint))
246            .or_insert_with(|| (location.to_owned(), reason));
247    }
248
249    /// Answers whether no issue collected is an error or fatal.
250    pub(crate) fn is_valid(&self) -> bool {
251        self.first_error().is_none()
252    }
253
254    /// Returns the first issue collected that is an error or fatal.
255    pub(crate) fn first_error(&self) -> Option<&Issue> {
256        self.issues
257            .iter()
258            .find(|issue| matches!(issue.severity, Severity::Fatal | Severity::Error))
259    }
260
261    /// Adds the issues and unevaluated constraints `other` collected.
262    pub(crate) fn absorb(&mut self, other: Self) {
263        self.issues.extend(other.issues);
264        for (key, value) in other.unevaluated {
265            self.unevaluated.entry(key).or_insert(value);
266        }
267    }
268
269    /// Finishes the report.
270    pub(crate) fn finish(self) -> Report {
271        Report {
272            issues: self.issues,
273            unevaluated: self
274                .unevaluated
275                .into_iter()
276                .map(
277                    |((structure, element, constraint), (location, reason))| Unevaluated {
278                        constraint,
279                        structure,
280                        element,
281                        location,
282                        reason,
283                    },
284                )
285                .collect(),
286        }
287    }
288}
289
290impl From<&Report> for OperationOutcome {
291    /// Builds the R4 `OperationOutcome`: one issue per [`Issue`], then one
292    /// `warning` of type `not-supported` per [`Unevaluated`].
293    ///
294    /// Each issue carries its location in `expression`, its message in
295    /// `details.text`, and its message id as a `details.coding` in
296    /// [`MESSAGE_SYSTEM`]; a constraint key is a coding whose system is the
297    /// declaring `StructureDefinition`'s canonical. A report with neither
298    /// issues nor unevaluated constraints becomes one `information` issue,
299    /// because `OperationOutcome.issue` is `1..*`
300    /// (<https://hl7.org/fhir/R4/operationoutcome.html>).
301    fn from(report: &Report) -> Self {
302        let mut issues: Vec<OperationOutcomeIssue> =
303            report.issues.iter().map(outcome_issue).collect();
304        issues.extend(report.unevaluated.iter().map(|unevaluated| {
305            outcome_issue(&Issue {
306                severity: Severity::Warning,
307                code: IssueType::NotSupported,
308                expression: unevaluated.location.clone(),
309                rule: Rule::Message(MessageId::ConstraintUnevaluated),
310                message: describe(unevaluated),
311            })
312        }));
313        if issues.is_empty() {
314            issues.push(outcome_issue(&Issue {
315                severity: Severity::Information,
316                code: IssueType::Informational,
317                expression: String::new(),
318                rule: Rule::Message(MessageId::AllOk),
319                message: "no issues, and every constraint met was evaluated".to_owned(),
320            }));
321        }
322        Self {
323            issue: issues,
324            ..Self::default()
325        }
326    }
327}
328
329/// The message of an unevaluated constraint.
330fn describe(unevaluated: &Unevaluated) -> String {
331    let what = match &unevaluated.constraint {
332        Constraint::Slicing => "the slicing".to_owned(),
333        Constraint::Discriminator { kind, path } => {
334            format!("the {kind} discriminator {path}")
335        }
336        Constraint::DiscriminatorValueSet { path, value_set } => {
337            format!("the discriminator {path} over the value set {value_set}")
338        }
339        Constraint::Invariant { key, .. } => format!("the invariant {key}"),
340        Constraint::Binding {
341            strength,
342            value_set,
343        } => match value_set {
344            Some(value_set) => format!("the {strength} binding to {value_set}"),
345            None => format!("the {strength} binding"),
346        },
347        Constraint::TypeProfile { profiles } => {
348            format!("the type profiles {}", profiles.join(", "))
349        }
350        Constraint::TargetProfile { profiles } => {
351            format!("the target profiles {}", profiles.join(", "))
352        }
353        Constraint::ClaimedProfile { url } => format!("the claimed profile {url}"),
354        Constraint::ExtensionContext => "the extension context".to_owned(),
355    };
356    format!(
357        "{what} on {} of {} was not evaluated: {}",
358        unevaluated.element, unevaluated.structure, unevaluated.reason
359    )
360}
361
362/// One `OperationOutcome.issue`.
363fn outcome_issue(issue: &Issue) -> OperationOutcomeIssue {
364    let coding = match &issue.rule {
365        Rule::Message(id) => Coding {
366            system: Some(primitives::Uri::from(MESSAGE_SYSTEM.to_owned())),
367            code: Some(primitives::Code::from(id.code().to_owned())),
368            ..Coding::default()
369        },
370        Rule::Constraint { key, source } => Coding {
371            system: Some(primitives::Uri::from(source.clone())),
372            code: Some(primitives::Code::from(key.clone())),
373            ..Coding::default()
374        },
375    };
376    OperationOutcomeIssue {
377        severity: primitives::Code::from(issue.severity.code().to_owned()),
378        code: primitives::Code::from(issue.code.code().to_owned()),
379        details: Some(CodeableConcept {
380            coding: vec![coding],
381            text: Some(primitives::String::from(issue.message.clone())),
382            ..CodeableConcept::default()
383        }),
384        expression: if issue.expression.is_empty() {
385            Vec::new()
386        } else {
387            vec![primitives::String::from(issue.expression.clone())]
388        },
389        ..OperationOutcomeIssue::default()
390    }
391}