Skip to main content

ferrobridge_validator/
set.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The package set: the packages given to the validator, checked for a
5//! complete dependency closure, and the `StructureDefinitions`, `ValueSets`
6//! and `CodeSystems` they carry, resolved by canonical.
7
8use std::cmp::Ordering;
9use std::collections::BTreeMap;
10use std::collections::BTreeSet;
11use std::path::Path;
12use std::sync::Arc;
13use std::sync::OnceLock;
14
15use fhir_types::codec::DecodeError;
16use fhir_types::codec::Json;
17use fhir_types::codec::Value;
18use fhir_types::r4::code_system::CodeSystem;
19use fhir_types::r4::structure_definition::StructureDefinition;
20use fhir_types::r4::value_set::ValueSet;
21
22use crate::canonical::Canonical;
23use crate::canonical::compare_versions;
24use crate::package::Content;
25use crate::package::Package;
26use crate::package::PackageError;
27
28/// A set of packages with a complete dependency closure.
29#[derive(Debug)]
30pub struct PackageSet {
31    packages: Vec<Package>,
32    structures: BTreeMap<String, Vec<Entry<StructureDefinition>>>,
33    value_sets: BTreeMap<String, Vec<Entry<ValueSet>>>,
34    code_systems: BTreeMap<String, Vec<Entry<CodeSystem>>>,
35    /// The dependency closure of each package, by position.
36    closures: Vec<BTreeSet<PackageId>>,
37}
38
39/// One resource the set carries, decoded on first use.
40#[derive(Debug)]
41struct Entry<T> {
42    version: Option<String>,
43    /// The package and file, as `name#version/file`.
44    package: String,
45    content: Content,
46    /// The package that carries it.
47    origin: PackageId,
48    decoded: OnceLock<Arc<T>>,
49}
50
51impl<T> Entry<T> {
52    /// Creates the entry of a resource not yet decoded.
53    fn new(version: Option<String>, package: String, content: Content, origin: PackageId) -> Self {
54        Self {
55            version,
56            package,
57            content,
58            origin,
59            decoded: OnceLock::new(),
60        }
61    }
62}
63
64/// Why a package set could not be built.
65#[derive(Debug, thiserror::Error)]
66pub enum LoadError {
67    /// A package could not be read.
68    #[error(transparent)]
69    Package(#[from] PackageError),
70    /// Two packages carry the same name and version.
71    #[error("package {name}#{version} is given twice")]
72    DuplicatePackage {
73        /// The package name.
74        name: String,
75        /// The package version.
76        version: String,
77    },
78    /// A dependency a manifest names is not in the set.
79    #[error(
80        "{package}#{version} depends on {dependency}#{dependency_version}, which the set does not hold"
81    )]
82    MissingDependency {
83        /// The dependent package.
84        package: String,
85        /// The dependent's version.
86        version: String,
87        /// The package it names.
88        dependency: String,
89        /// The version it names.
90        dependency_version: String,
91    },
92}
93
94/// Why a canonical did not resolve to one resource of the type asked for.
95#[derive(Debug, thiserror::Error)]
96pub enum ResolveError {
97    /// No package of the set carries the canonical.
98    #[error("no resource {canonical} of the type asked for in the package set")]
99    NotFound {
100        /// The canonical asked for.
101        canonical: String,
102    },
103    /// The canonical names a contained resource, which a package does not
104    /// index.
105    #[error("{canonical} names a contained resource, which the package set does not resolve")]
106    Fragment {
107        /// The canonical asked for.
108        canonical: String,
109    },
110    /// Two packages carry different definitions under one URL and version.
111    #[error("{canonical} resolves to different definitions in {first} and {second}")]
112    Ambiguous {
113        /// The canonical asked for.
114        canonical: String,
115        /// The first package and file, as `name#version/file`.
116        first: String,
117        /// The second package and file, as `name#version/file`.
118        second: String,
119    },
120    /// The file could not be read.
121    #[error("{canonical} in {package} cannot be read")]
122    Io {
123        /// The canonical asked for.
124        canonical: String,
125        /// The package and file, as `name#version/file`.
126        package: String,
127        /// The I/O error.
128        #[source]
129        source: std::io::Error,
130    },
131    /// The file does not parse as JSON.
132    #[error("{canonical} in {package} is no JSON document")]
133    Json {
134        /// The canonical asked for.
135        canonical: String,
136        /// The package and file, as `name#version/file`.
137        package: String,
138        /// The parse error.
139        #[source]
140        source: serde_json::Error,
141    },
142    /// The resource does not decode into the R4 model of its type.
143    #[error("{canonical} in {package} does not decode into the R4 model of its type")]
144    Decode {
145        /// The canonical asked for.
146        canonical: String,
147        /// The package and file, as `name#version/file`.
148        package: String,
149        /// The decode error, located by its element path.
150        #[source]
151        source: DecodeError,
152    },
153}
154
155impl PackageSet {
156    /// Reads every package at `paths` (`.tgz` files or unpacked directories)
157    /// and builds the set.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`LoadError`] when a package cannot be read, or as
162    /// [`PackageSet::new`] does.
163    pub fn load<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> Result<Self, LoadError> {
164        let packages = paths
165            .into_iter()
166            .map(|path| Package::read(path.as_ref()))
167            .collect::<Result<Vec<_>, _>>()?;
168        Self::new(packages)
169    }
170
171    /// Builds the set from packages already read, checking the closure.
172    ///
173    /// Every dependency a manifest names SHALL be listed, and a version
174    /// whose patch is `x` accepts the highest patch present
175    /// (<https://hl7.org/fhir/packages.html>, §2.1.10.2).
176    ///
177    /// # Errors
178    ///
179    /// Returns [`LoadError::DuplicatePackage`] for a name and version given
180    /// twice, and [`LoadError::MissingDependency`] naming the first dependency
181    /// the set does not hold.
182    pub fn new(mut packages: Vec<Package>) -> Result<Self, LoadError> {
183        packages.sort_by(|a, b| {
184            a.name()
185                .cmp(b.name())
186                .then_with(|| compare_versions(a.version(), b.version()))
187        });
188        for pair in packages.windows(2) {
189            if let [first, second] = pair
190                && first.name() == second.name()
191                && first.version() == second.version()
192            {
193                return Err(LoadError::DuplicatePackage {
194                    name: first.name().to_owned(),
195                    version: first.version().to_owned(),
196                });
197            }
198        }
199        for package in &packages {
200            for (dependency, wanted) in package.dependencies() {
201                let held = packages.iter().any(|candidate| {
202                    candidate.name() == dependency && version_satisfies(candidate.version(), wanted)
203                });
204                if !held {
205                    return Err(LoadError::MissingDependency {
206                        package: package.name().to_owned(),
207                        version: package.version().to_owned(),
208                        dependency: dependency.clone(),
209                        dependency_version: wanted.clone(),
210                    });
211                }
212            }
213        }
214        let mut structures: BTreeMap<String, Vec<Entry<StructureDefinition>>> = BTreeMap::new();
215        let mut value_sets: BTreeMap<String, Vec<Entry<ValueSet>>> = BTreeMap::new();
216        let mut code_systems: BTreeMap<String, Vec<Entry<CodeSystem>>> = BTreeMap::new();
217        for (index, package) in packages.iter().enumerate() {
218            let label = format!("{}#{}", package.name(), package.version());
219            let origin = PackageId(index);
220            for resource in package.resources() {
221                let Some(url) = &resource.url else { continue };
222                let version = resource.version.clone();
223                let source = format!("{label}/{}", resource.file);
224                let content = resource.content.clone();
225                match resource.type_name.as_str() {
226                    "StructureDefinition" => structures
227                        .entry(url.clone())
228                        .or_default()
229                        .push(Entry::new(version, source, content, origin)),
230                    "ValueSet" => value_sets
231                        .entry(url.clone())
232                        .or_default()
233                        .push(Entry::new(version, source, content, origin)),
234                    "CodeSystem" => code_systems
235                        .entry(url.clone())
236                        .or_default()
237                        .push(Entry::new(version, source, content, origin)),
238                    _ => {}
239                }
240            }
241        }
242        let closures = (0..packages.len())
243            .map(|index| dependency_closure(&packages, index))
244            .collect();
245        Ok(Self {
246            packages,
247            structures,
248            value_sets,
249            code_systems,
250            closures,
251        })
252    }
253
254    /// Returns the packages of the set, sorted by name and version.
255    #[must_use]
256    pub fn packages(&self) -> &[Package] {
257        &self.packages
258    }
259
260    /// Resolves `canonical` to the `StructureDefinition` it names.
261    ///
262    /// `url|version` resolves to that version. A bare `url` resolves to its
263    /// one version, or to the latest when the set carries several, as R4
264    /// asks (<https://hl7.org/fhir/R4/references.html#canonical>, §2.3.0.5);
265    /// the order is [`compare_versions`], and a definition without a version
266    /// sorts below every versioned one.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`ResolveError`] when nothing matches, the canonical names a
271    /// contained resource, two different definitions share the URL and
272    /// version, or the definition does not decode.
273    pub fn structure_definition(
274        &self,
275        canonical: &str,
276    ) -> Result<Arc<StructureDefinition>, ResolveError> {
277        self.structure_definition_in(canonical, None)
278            .map(|(definition, _)| definition)
279    }
280
281    /// Resolves `canonical` to the `ValueSet` it names, by the rules of
282    /// [`PackageSet::structure_definition`].
283    ///
284    /// # Errors
285    ///
286    /// Returns [`ResolveError`] as [`PackageSet::structure_definition`] does.
287    pub fn value_set(&self, canonical: &str) -> Result<Arc<ValueSet>, ResolveError> {
288        self.value_set_in(canonical, None)
289            .map(|(value_set, _)| value_set)
290    }
291
292    /// Resolves `canonical` to the `CodeSystem` it names, by the rules of
293    /// [`PackageSet::structure_definition`].
294    ///
295    /// # Errors
296    ///
297    /// Returns [`ResolveError`] as [`PackageSet::structure_definition`] does.
298    pub fn code_system(&self, canonical: &str) -> Result<Arc<CodeSystem>, ResolveError> {
299        self.code_system_in(canonical, None)
300            .map(|(code_system, _)| code_system)
301    }
302
303    /// Resolves `canonical` as referenced from the package `scope`, and
304    /// returns the definition with the package that carries it.
305    pub(crate) fn structure_definition_in(
306        &self,
307        canonical: &str,
308        scope: Option<PackageId>,
309    ) -> Result<(Arc<StructureDefinition>, PackageId), ResolveError> {
310        resolve(
311            &self.structures,
312            canonical,
313            "StructureDefinition",
314            self.closure(scope),
315        )
316    }
317
318    /// Resolves the `ValueSet` `canonical` names as referenced from `scope`.
319    pub(crate) fn value_set_in(
320        &self,
321        canonical: &str,
322        scope: Option<PackageId>,
323    ) -> Result<(Arc<ValueSet>, PackageId), ResolveError> {
324        resolve(&self.value_sets, canonical, "ValueSet", self.closure(scope))
325    }
326
327    /// Resolves the `CodeSystem` `canonical` names as referenced from `scope`.
328    pub(crate) fn code_system_in(
329        &self,
330        canonical: &str,
331        scope: Option<PackageId>,
332    ) -> Result<(Arc<CodeSystem>, PackageId), ResolveError> {
333        resolve(
334            &self.code_systems,
335            canonical,
336            "CodeSystem",
337            self.closure(scope),
338        )
339    }
340
341    /// Returns the dependency closure of the package `scope`.
342    fn closure(&self, scope: Option<PackageId>) -> Option<&BTreeSet<PackageId>> {
343        scope.and_then(|PackageId(index)| self.closures.get(index))
344    }
345}
346
347/// One package of a set, by its position in [`PackageSet::packages`].
348#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
349pub(crate) struct PackageId(usize);
350
351/// The dependency closure of the package at `start`: itself, and for every
352/// dependency a manifest names the highest version of the set that
353/// satisfies it, followed transitively.
354fn dependency_closure(packages: &[Package], start: usize) -> BTreeSet<PackageId> {
355    let mut seen = BTreeSet::new();
356    let mut pending = vec![start];
357    while let Some(index) = pending.pop() {
358        if !seen.insert(PackageId(index)) {
359            continue;
360        }
361        let Some(package) = packages.get(index) else {
362            continue;
363        };
364        for (dependency, wanted) in package.dependencies() {
365            let best = packages
366                .iter()
367                .enumerate()
368                .filter(|(_, candidate)| {
369                    candidate.name() == dependency && version_satisfies(candidate.version(), wanted)
370                })
371                .max_by(|(_, a), (_, b)| compare_versions(a.version(), b.version()));
372            if let Some((found, _)) = best {
373                pending.push(found);
374            }
375        }
376    }
377    seen
378}
379
380/// Resolves `canonical` among `index`, the resources of type `kind`, within
381/// `closure` when given.
382fn resolve<T: Json>(
383    index: &BTreeMap<String, Vec<Entry<T>>>,
384    canonical: &str,
385    kind: &str,
386    closure: Option<&BTreeSet<PackageId>>,
387) -> Result<(Arc<T>, PackageId), ResolveError> {
388    let parsed = Canonical::parse(canonical);
389    if parsed.fragment().is_some() {
390        return Err(ResolveError::Fragment {
391            canonical: canonical.to_owned(),
392        });
393    }
394    let not_found = || ResolveError::NotFound {
395        canonical: canonical.to_owned(),
396    };
397    let all = index.get(parsed.url()).ok_or_else(not_found)?;
398    let scoped: Vec<&Entry<T>> = all
399        .iter()
400        .filter(|entry| closure.is_none_or(|closure| closure.contains(&entry.origin)))
401        .collect();
402    // NOTE: packages.html names a package's dependencies and is silent on a canonical they do
403    // not carry; no specification governs this: our own design, it falls back to the whole set.
404    let entries: Vec<&Entry<T>> = if scoped.is_empty() || parsed.version().is_some() {
405        all.iter().collect()
406    } else {
407        scoped
408    };
409    let wanted = if let Some(version) = parsed.version() {
410        Some(version)
411    } else {
412        entries
413            .iter()
414            .max_by(|a, b| order_versions(a.version.as_deref(), b.version.as_deref()))
415            .ok_or_else(not_found)?
416            .version
417            .as_deref()
418    };
419    let matching: Vec<&Entry<T>> = entries
420        .iter()
421        .copied()
422        .filter(|entry| entry.version.as_deref() == wanted)
423        .collect();
424    let (entry, others) = matching.split_first().ok_or_else(not_found)?;
425    let origin = entry.origin;
426    if let Some(decoded) = entry.decoded.get() {
427        return Ok((Arc::clone(decoded), origin));
428    }
429    let bytes = read(canonical, entry)?;
430    for other in others {
431        // NOTE: no specification governs this: our own design; one definition
432        // carried byte for byte by two packages is one definition, not a conflict.
433        if read(canonical, other)? != bytes {
434            return Err(ResolveError::Ambiguous {
435                canonical: canonical.to_owned(),
436                first: entry.package.clone(),
437                second: other.package.clone(),
438            });
439        }
440    }
441    let decoded = Arc::new(decode(canonical, entry, &bytes, kind)?);
442    Ok((Arc::clone(entry.decoded.get_or_init(|| decoded)), origin))
443}
444
445/// Reads the bytes of the resource `entry` points at.
446fn read<T>(canonical: &str, entry: &Entry<T>) -> Result<Arc<[u8]>, ResolveError> {
447    entry.content.read().map_err(|source| ResolveError::Io {
448        canonical: canonical.to_owned(),
449        package: entry.package.clone(),
450        source,
451    })
452}
453
454/// Decodes `bytes`, the resource of type `kind` that `entry` points at.
455fn decode<T: Json>(
456    canonical: &str,
457    entry: &Entry<T>,
458    bytes: &[u8],
459    kind: &str,
460) -> Result<T, ResolveError> {
461    let document: Value = serde_json::from_slice(bytes).map_err(|source| ResolveError::Json {
462        canonical: canonical.to_owned(),
463        package: entry.package.clone(),
464        source,
465    })?;
466    let mut path = fhir_types::codec::Path::root(kind);
467    let decode = |source| ResolveError::Decode {
468        canonical: canonical.to_owned(),
469        package: entry.package.clone(),
470        source,
471    };
472    let object = fhir_types::codec::expect_object(&document, &path).map_err(decode)?;
473    T::from_json(object, &mut path).map_err(decode)
474}
475
476/// Orders two optional versions, an absent one below every present one.
477fn order_versions(left: Option<&str>, right: Option<&str>) -> Ordering {
478    match (left, right) {
479        (None, None) => Ordering::Equal,
480        (None, Some(_)) => Ordering::Less,
481        (Some(_), None) => Ordering::Greater,
482        (Some(left), Some(right)) => compare_versions(left, right),
483    }
484}
485
486/// Answers whether `version` satisfies the dependency version `wanted`: the
487/// same version, or the same major and minor when the patch of `wanted` is
488/// `x` (packages.html §2.1.10.2).
489fn version_satisfies(version: &str, wanted: &str) -> bool {
490    match wanted.strip_suffix(".x") {
491        Some(prefix) => version
492            .strip_prefix(prefix)
493            .and_then(|rest| rest.strip_prefix('.'))
494            .is_some_and(|patch| !patch.is_empty() && !patch.contains('.')),
495        None => version == wanted,
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use std::path::Path;
502
503    use super::PackageId;
504    use super::PackageSet;
505    use super::version_satisfies;
506
507    #[test]
508    fn a_patch_wildcard_accepts_any_patch_of_its_minor() {
509        assert!(version_satisfies("4.0.1", "4.0.1"));
510        assert!(version_satisfies("4.0.1", "4.0.x"));
511        assert!(!version_satisfies("4.1.0", "4.0.x"));
512        assert!(!version_satisfies("4.0.1", "4.0.2"));
513    }
514
515    // scripts/vendor/profile-packages.sh fetches the EU trees at build time; the test
516    // returns early without them, as the other build-time corpora do.
517    #[test]
518    fn a_canonical_eps_names_resolves_to_the_eu_base_version_eps_depends_on() {
519        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/specs/fhir-packages");
520        if !root
521            .join("hl7.fhir.eu.eps#1.0.0-ballot/package/package.json")
522            .is_file()
523        {
524            return;
525        }
526        let mut paths = vec![
527            Path::new(env!("CARGO_MANIFEST_DIR"))
528                .join("../../tools/fhir-codegen/vendor/hl7.fhir.r4.core"),
529        ];
530        for entry in std::fs::read_dir(&root).expect("the fetched packages") {
531            let path = entry.expect("a fetched package").path();
532            if path.join("package/package.json").is_file() {
533                paths.push(path);
534            }
535        }
536        let set = PackageSet::load(paths).expect("the EU closure");
537        let eps = set
538            .packages()
539            .iter()
540            .position(|package| package.name() == "hl7.fhir.eu.eps")
541            .expect("the EPS package");
542        let canonical = "http://hl7.eu/fhir/base/StructureDefinition/patient-eu-core";
543        let (scoped, _) = set
544            .structure_definition_in(canonical, Some(PackageId(eps)))
545            .expect("patient-eu-core in the EPS closure");
546        assert_eq!(
547            scoped
548                .version
549                .as_ref()
550                .and_then(|version| version.value.as_deref()),
551            Some("2.0.0")
552        );
553        let latest = set
554            .structure_definition(canonical)
555            .expect("patient-eu-core");
556        assert_eq!(
557            latest
558                .version
559                .as_ref()
560                .and_then(|version| version.value.as_deref()),
561            Some("2.0.1")
562        );
563    }
564}