diff --git a/Cargo.lock b/Cargo.lock index c1be29c57..661baafcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3354,6 +3354,10 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "thiserror 2.0.18", "tokio", ] diff --git a/crates/fhir-validator/Cargo.toml b/crates/fhir-validator/Cargo.toml index c268655f9..1a9679400 100644 --- a/crates/fhir-validator/Cargo.toml +++ b/crates/fhir-validator/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true rust-version.workspace = true description = "FHIR resource validator for HFS — FHIR Schema based structural and profile validation" homepage = "https://github.com/HeliosSoftware/hfs/tree/main/crates/fhir-validator" +readme = "README.md" keywords = ["helios-software", "hl7", "fhir", "validation", "fhir-schema"] [features] @@ -35,10 +36,15 @@ serde_json = { workspace = true, features = ["preserve_order"] } indexmap = { version = "2", features = ["serde"] } # Schema pack (de)compression. flate2 = "1" +# FHIR NPM `.tgz` extract (package materialization). +tar = "0.4" +# Optional integrity sidecar for cached package tarballs. +sha2 = "0.10" # Primitive value regexes (FHIR spec patterns carried in the packs). regex = "1" # Dyn-compatible async TerminologyProvider. async-trait = "0.1" +thiserror = "2" clap = { version = "4", features = ["derive"], optional = true } helios-fhirpath = { path = "../fhirpath", version = "0.2.1", optional = true, default-features = false } @@ -46,6 +52,7 @@ helios-fhirpath = { path = "../fhirpath", version = "0.2.1", optional = true, de serde = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt", "macros"] } +tempfile = "3" [[bin]] name = "generate-schema-packs" diff --git a/crates/fhir-validator/README.md b/crates/fhir-validator/README.md new file mode 100644 index 000000000..568d59705 --- /dev/null +++ b/crates/fhir-validator/README.md @@ -0,0 +1,36 @@ +# helios-fhir-validator + +FHIR resource validation for [Helios FHIR Server](https://github.com/HeliosSoftware/hfs), built on the [FHIR Schema](https://fhir-schema.github.io/fhir-schema/) approach: StructureDefinitions compile to differential JSON-schema-like forms and validate via **cooperative schema sets** (no snapshot flattening). + +## Features + +- Structural validation: unknown elements, cardinality, choices, fixed/pattern, `maxLength` / `minValue` / `maxValue`, primitives +- Profile layering: `meta.profile`, caller profiles, slicing (pattern / type / profile / binding + reslices), extension sugar +- Deferred effects: FHIRPath invariants (`fhirpath` feature) and terminology bindings (required; optional extensible warnings) +- Embedded core schema + terminology packs for R4 / R4B / R5 / R6 (feature-gated) +- FHIR NPM / IG package cache, offline dependency resolution, `fhirVersions` checks +- QuestionnaireResponse validation against a Questionnaire definition +- Authoring helpers (`editor`) for “what can I add here?” UIs + +## Quick start + +```rust +use helios_fhir_validator::{SchemaRegistry, ValidationOptions, Validator}; +use std::sync::Arc; + +let mut registry = SchemaRegistry::new(); +// …insert schemas or use packs::core_registry(FhirVersion::R4) +let validator = Validator::new(Arc::new(registry)); +let outcome = validator.validate_sync(&resource, &ValidationOptions::default()); +``` + +## Packages + +See [docs/packages.md](docs/packages.md) for cache layout, `HFS_FHIR_PACKAGE_*` operator config, and the bundled sample IG under `tests/fixtures/packages/`. + +## Tests + +```bash +cargo test -p helios-fhir-validator +cargo test -p helios-fhir-validator -- --ignored # whole-pack smoke +``` diff --git a/crates/fhir-validator/docs/packages.md b/crates/fhir-validator/docs/packages.md new file mode 100644 index 000000000..9817f0073 --- /dev/null +++ b/crates/fhir-validator/docs/packages.md @@ -0,0 +1,80 @@ +# FHIR NPM package materialization + +Package overlays use the same `SchemaRegistry` + `CompositeResolver` path as +core packs and tenant-uploaded StructureDefinitions (#232). This document +covers **materialization proper**: cache layout, sources, dependency +resolution, and operator configuration. + +## Cache vs sources + +| Concept | Role | +|---------|------| +| **Cache** (`HFS_FHIR_PACKAGE_CACHE`) | Durable expanded packages: `{cache}/{name}/{version}/` | +| **Sources** (`HFS_FHIR_PACKAGE_SOURCES`) | Where packages are **installed from** at boot (local or URL) | +| **Roots** (`HFS_FHIR_PACKAGES`) | Which `name@version` layers to load; defaults to packages installed from sources | + +`.staging/` and `.downloads/` under the cache root are **internal** (temp unpack / +HTTP fetch). They are not package sources. + +## Accepted local sources (`PackageCache::ensure_from_path`) + +- FHIR NPM `.tgz` / `.tar.gz` file (any path, e.g. IG publisher + `output/atrius.fhir.r4.india.en.tgz` or `output/package.tgz`) +- Expanded package directory with `package.json` or `package/package.json` +- IG publisher **`output/`** directory: prefers `package.tgz`; if several + `*.tgz` exist, pass one file explicitly (do **not** treat the whole HTML + tree as a package) + +## Configuration + +| Variable | Purpose | +|----------|---------| +| `HFS_FHIR_PACKAGE_CACHE` | Cache root (required when sources/packages are set) | +| `HFS_FHIR_PACKAGE_SOURCES` | Comma-separated local paths and/or `http(s)://…/*.tgz` URLs | +| `HFS_FHIR_PACKAGES` | Optional `name@version` roots; if omitted, uses ids from sources | + +### Examples + +Bundled test fixture (check out of tree): + +```bash +export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache +export HFS_FHIR_PACKAGE_SOURCES=crates/fhir-validator/tests/fixtures/packages/sample.tgz +export HFS_VALIDATION_MODE=enforce +# defaults to example.fhir.r4.sample@0.1.0 from the tarball +``` + +Publisher tarball or `output/` on disk: + +```bash +export HFS_FHIR_PACKAGE_SOURCES=/path/to/ig/output/package.tgz +# or: /path/to/ig/output (picks package.tgz when unique) +``` + +Published URL: + +```bash +export HFS_FHIR_PACKAGE_SOURCES=https://example.org/fhir/r4/sample/package.tgz +``` + +See `tests/fixtures/packages/README.md` for the sample IG contents and rebuild script. + +## Resolver order + +`CompositeResolver` (earlier wins): + +1. Tenant stored-StructureDefinition overlay (optional) +2. Package layers — dependents before transitive deps +3. Embedded core schema pack + +## What is loaded + +Only **StructureDefinition** resources become schemas. Abstract infrastructure +roots (`Element`, `BackboneElement`, `Resource`, `DomainResource`) are skipped. +CodeSystem / ValueSet files are discovered for operators but must be imported +via HTS, not the schema registry. + +## Library API + +See `helios_fhir_validator::packages`: `PackageCache`, `ensure_from_path`, +`resolve_packages`, `materialize_package`, `materialize_package_layers`. diff --git a/crates/fhir-validator/src/bin/validator_cli.rs b/crates/fhir-validator/src/bin/validator_cli.rs index a1c67e758..425ee424c 100644 --- a/crates/fhir-validator/src/bin/validator_cli.rs +++ b/crates/fhir-validator/src/bin/validator_cli.rs @@ -134,6 +134,7 @@ fn main() -> ExitCode { profiles: args.profiles.clone(), use_meta_profiles: !args.no_meta_profiles, unknown_profile: UnknownProfilePolicy::Warn, + ..Default::default() }; let outcome = validator.validate_sync(&resource, &opts); diff --git a/crates/fhir-validator/src/converter/mod.rs b/crates/fhir-validator/src/converter/mod.rs index 065f98b49..20cc1d03c 100644 --- a/crates/fhir-validator/src/converter/mod.rs +++ b/crates/fhir-validator/src/converter/mod.rs @@ -101,7 +101,12 @@ pub(crate) struct Ed { pub short: Option, #[serde(default)] pub constraint: Vec, - /// Everything else — scanned for `fixed[x]` / `pattern[x]`. + #[serde(rename = "maxLength")] + pub max_length: Option, + #[serde(rename = "sliceIsConstraining")] + pub slice_is_constraining: Option, + /// Everything else — scanned for `fixed[x]` / `pattern[x]` / + /// `minValue[x]` / `maxValue[x]`. #[serde(flatten)] pub rest: serde_json::Map, } diff --git a/crates/fhir-validator/src/converter/slicing.rs b/crates/fhir-validator/src/converter/slicing.rs index 073a4dad9..1baf4b460 100644 --- a/crates/fhir-validator/src/converter/slicing.rs +++ b/crates/fhir-validator/src/converter/slicing.rs @@ -1,19 +1,29 @@ //! Discriminator → slice-match translation. //! +//! Discriminator paths are the FHIR "restricted FHIRPath" subset: dotted +//! element selections, `$this`, `extension('url')`, `ofType(Type)`, and +//! `resolve()`. All but `resolve()` are parsed and compiled here; +//! `resolve()` needs the instance graph and stays unsupported (slice kept +//! without match/min). +//! //! A `value`/`pattern` discriminator at path `P` becomes a partial-match //! pattern built from the `fixed[x]`/`pattern[x]` constant found at `P` -//! inside the slice's subtree (`$this` meaning the item root). Discriminator -//! types we cannot evaluate yet (`type`, `profile`, `exists`, and any path -//! containing `resolve()` or `extension(...)`) produce a slice with **no -//! match and no minimum**: it can never produce false cardinality errors, -//! its constraints simply stay dormant until the matcher lands (Phase 7), -//! and the generator surfaces a warning. +//! inside the slice's subtree (`$this` meaning the item root). Paths that +//! traverse `extension('url')` compile to a dedicated `extension` matcher +//! instead (array containment is not expressible as a plain pattern). +//! +//! `exists` discriminators compile to an `exists` matcher: expected +//! presence/absence per path, read from the slice differential (`min >= 1` +//! ⇒ must exist, `max = 0` ⇒ must be absent). +//! +//! `type`, `profile`, and `binding` discriminators become typed [`Match`] +//! values evaluated at runtime by the engine. use super::EdDiscriminator; -use super::tree::{SliceNode, finalize}; +use super::tree::{SliceNode, capitalize, finalize}; use crate::schema::{Match, Slice, Slicing}; use indexmap::IndexMap; -use serde_json::{Map, Value}; +use serde_json::{Map, Value, json}; use std::sync::Arc; pub(super) fn build_slicing( @@ -29,11 +39,17 @@ pub(super) fn build_slicing( node, min, max, - extension_profile: _, + extension_profile, + reslice, + slice_is_constraining, } = slice_node; - let match_ = build_match(&node, discriminators); - if match_.is_none() { + let match_ = build_match(&node, discriminators, extension_profile.as_deref()); + // Constraining slices inherit the parent's matcher; without a match + // (and without reslice/constraining) nothing can ever match → drop min. + let can_match = + match_.is_some() || reslice.is_some() || slice_is_constraining == Some(true); + if match_.is_none() && !can_match { warnings.push(format!( "slice '{name}': discriminator(s) {:?} not translatable to a match; \ slice kept without match or min", @@ -48,14 +64,12 @@ pub(super) fn build_slicing( out.insert( name, Slice { - // Without a matcher the slice must not enforce a minimum — - // nothing can ever match it. - min: if match_.is_some() { min } else { None }, + min: if can_match { min } else { None }, max, match_, order: None, - reslice: None, - slice_is_constraining: None, + reslice, + slice_is_constraining, schema: schema.map(Arc::new), }, ); @@ -71,31 +85,288 @@ pub(super) fn build_slicing( }) } -/// Build a pattern match from `value`/`pattern` discriminators, reading the -/// constant at each discriminator path out of the slice subtree. -fn build_match(node: &super::tree::Node, discriminators: &[EdDiscriminator]) -> Option { - if discriminators.is_empty() { +// --------------------------------------------------------------------------- +// Restricted-FHIRPath discriminator paths +// --------------------------------------------------------------------------- + +/// One parsed segment of a restricted discriminator path. +#[derive(Debug, Clone, PartialEq)] +enum DSeg { + /// Plain element selection (`code`, `component`). + Key(String), + /// `extension('url')` — select a particular extension by url. + Extension(String), + /// `ofType(Type)` — pick one branch of a choice element. + OfType(String), + /// `resolve()` — follow a Reference; unsupported. + Resolve, +} + +/// A resolved segment of the runtime match path: choice elements and +/// `ofType()` collapse into the concrete JSON key. +#[derive(Debug, Clone, PartialEq)] +enum RSeg { + Key(String), + Extension(String), +} + +/// Parse a discriminator path. `$this` (alone or as a leading segment) +/// contributes no segments. Returns `None` on anything outside the +/// restricted grammar. +fn parse_disc_path(path: &str) -> Option> { + let mut out = Vec::new(); + for raw in split_top_level(path) { + let raw = raw.trim(); + if raw == "$this" { + continue; + } + if raw == "resolve()" { + out.push(DSeg::Resolve); + continue; + } + if let Some(url) = function_arg(raw, "extension") { + out.push(DSeg::Extension(url)); + continue; + } + if let Some(ty) = function_arg(raw, "ofType") { + out.push(DSeg::OfType(ty)); + continue; + } + if raw.is_empty() || raw.contains('(') || raw.contains(')') { + return None; + } + out.push(DSeg::Key(raw.to_string())); + } + Some(out) +} + +/// Split on top-level `.` only — extension urls contain dots inside `(...)`. +fn split_top_level(path: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (i, c) in path.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + '.' if depth == 0 => { + out.push(&path[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&path[start..]); + out +} + +/// `function_arg("extension('http://x')", "extension")` → `Some("http://x")`. +fn function_arg(segment: &str, name: &str) -> Option { + let inner = segment + .strip_prefix(name)? + .strip_prefix('(')? + .strip_suffix(')')?; + let arg = inner.trim().trim_matches(|c| c == '\'' || c == '"'); + if arg.is_empty() { return None; } + Some(arg.to_string()) +} - let mut this_constant: Option = None; - let mut pattern = Map::new(); +/// Navigate parsed segments through the slice subtree, resolving choice +/// elements and `ofType()` to concrete JSON keys and `extension('url')` to +/// the matching extension sub-slice. +fn resolve_path<'a>( + node: &'a super::tree::Node, + segs: &[DSeg], +) -> Option<(&'a super::tree::Node, Vec)> { + let mut current = node; + let mut out = Vec::new(); + let mut i = 0; + while i < segs.len() { + match &segs[i] { + DSeg::Resolve => return None, + // `ofType()` is consumed with the key it follows; a leading one + // has nothing to type-select. + DSeg::OfType(_) => return None, + DSeg::Extension(url) => { + let ext = current.children.get("extension")?; + let slice = find_extension_slice(ext, url)?; + current = &slice.node; + out.push(RSeg::Extension(url.clone())); + i += 1; + } + DSeg::Key(name) => { + let key = match segs.get(i + 1) { + Some(DSeg::OfType(ty)) => { + i += 1; + format!("{name}{}", capitalize(ty)) + } + _ => name.clone(), + }; + let (child, resolved) = child_resolving_choice(current, &key)?; + current = child; + out.push(RSeg::Key(resolved)); + i += 1; + } + } + } + Some((current, out)) +} + +/// Child lookup that sees through choice elements: a declarer with a single +/// branch (`value` → `valueQuantity`) resolves to that branch, and a bare +/// branch (`valueQuantity` present without its declarer) is found via +/// `choiceOf` when it is unambiguous. +fn child_resolving_choice<'a>( + node: &'a super::tree::Node, + name: &str, +) -> Option<(&'a super::tree::Node, String)> { + if let Some(child) = node.children.get(name) { + if let Some(choices) = &child.schema.choices + && choices.len() == 1 + && let Some(branch) = node.children.get(&choices[0]) + { + return Some((branch, choices[0].clone())); + } + return Some((child, name.to_string())); + } + let mut branches = node + .children + .iter() + .filter(|(_, c)| c.schema.choice_of.as_deref() == Some(name)); + match (branches.next(), branches.next()) { + (Some((key, child)), None) => Some((child, key.clone())), + _ => None, + } +} + +/// Find the extension sub-slice discriminated by `url` — either sliced by +/// type profile (sugar) or by a fixed/pattern `url` child. +fn find_extension_slice<'a>(ext: &'a super::tree::Node, url: &str) -> Option<&'a SliceNode> { + ext.slices.values().find(|s| { + s.extension_profile.as_deref() == Some(url) + || s.node + .children + .get("url") + .and_then(|u| u.schema.fixed.as_ref().or(u.schema.pattern.as_ref())) + .and_then(Value::as_str) + == Some(url) + }) +} + +// --------------------------------------------------------------------------- +// Match building +// --------------------------------------------------------------------------- + +/// Build a match from discriminators, reading constants / type / binding / +/// profile / cardinality metadata out of the slice subtree. +fn build_match( + node: &super::tree::Node, + discriminators: &[EdDiscriminator], + extension_profile: Option<&str>, +) -> Option { + if discriminators.is_empty() { + return None; + } + let mut parsed: Vec<(&EdDiscriminator, Vec)> = Vec::with_capacity(discriminators.len()); for disc in discriminators { - if !matches!(disc.type_.as_str(), "value" | "pattern") { - return None; + let segs = parse_disc_path(&disc.path)?; + if segs.contains(&DSeg::Resolve) { + return None; // needs the instance graph } - if disc.path.contains("resolve()") || disc.path.contains("extension(") { + parsed.push((disc, segs)); + } + + let kinds: Vec<&str> = discriminators.iter().map(|d| d.type_.as_str()).collect(); + if kinds.iter().all(|k| matches!(*k, "value" | "pattern")) { + return build_pattern_match(node, &parsed); + } + if kinds.iter().all(|k| *k == "exists") { + return build_exists_match(node, &parsed); + } + + // Single non-pattern discriminator (homogeneous set of one kind). + if kinds.iter().all(|k| *k == "type") && parsed.len() == 1 { + let (target, _) = resolve_path(node, &parsed[0].1)?; + let type_code = target.schema.type_.as_ref()?; + return Some(Match { + type_: Some("type".to_string()), + value: Some(Value::String(type_code.clone())), + resolve_ref: None, + }); + } + if kinds.iter().all(|k| *k == "profile") && parsed.len() == 1 { + let (target, _) = resolve_path(node, &parsed[0].1)?; + let profile = extension_profile + .map(str::to_string) + .or_else(|| target.type_profiles.first().cloned()) + .or_else(|| target.schema.url.clone()) + .or_else(|| { + target + .schema + .refers + .as_ref() + .and_then(|r| r.first().cloned()) + })?; + return Some(Match { + type_: Some("profile".to_string()), + value: Some(Value::String(profile)), + resolve_ref: None, + }); + } + if kinds.iter().all(|k| *k == "binding") && parsed.len() == 1 { + let (target, _) = resolve_path(node, &parsed[0].1)?; + let vs = target.schema.binding.as_ref()?.value_set.clone(); + return Some(Match { + type_: Some("binding".to_string()), + value: Some(Value::String(vs)), + resolve_ref: None, + }); + } + + None +} + +fn build_pattern_match( + node: &super::tree::Node, + discs: &[(&EdDiscriminator, Vec)], +) -> Option { + // A path traversing extension('url') is not expressible as a plain + // partial-match pattern (patterns match arrays prefix-wise, extensions + // need containment); it compiles to a dedicated `extension` matcher, + // only supported as the sole discriminator. + if discs + .iter() + .any(|(_, segs)| segs.iter().any(|s| matches!(s, DSeg::Extension(_)))) + { + if discs.len() != 1 { return None; } - let constant = constant_at(node, &disc.path)?; - if disc.path == "$this" { - if discriminators.len() > 1 { + return build_extension_match(node, &discs[0].1); + } + + let mut this_constant: Option = None; + let mut pattern = Map::new(); + + for (_, segs) in discs { + let (target, rsegs) = resolve_path(node, segs)?; + let constant = constant_of(target)?; + if segs.is_empty() { + if discs.len() > 1 { return None; // $this plus siblings — ambiguous } this_constant = Some(constant); } else { - insert_nested(&mut pattern, &disc.path, constant); + let keys: Vec<&str> = rsegs + .iter() + .map(|r| match r { + RSeg::Key(k) => k.as_str(), + RSeg::Extension(_) => unreachable!("extension paths handled above"), + }) + .collect(); + insert_nested(&mut pattern, &keys, constant); } } @@ -111,38 +382,130 @@ fn build_match(node: &super::tree::Node, discriminators: &[EdDiscriminator]) -> }) } -/// The fixed/pattern constant at `path` within the slice subtree -/// (`$this` → the subtree root itself). -fn constant_at(node: &super::tree::Node, path: &str) -> Option { - let target = if path == "$this" { - node +/// `extension('u1')[.extension('u2')…][.key…]` — compiled to a nested +/// `extension` matcher: `{url, extension: {…}}` for each chained url, with +/// the trailing keys becoming a partial-match `pattern` on the innermost +/// extension element. +fn build_extension_match(node: &super::tree::Node, segs: &[DSeg]) -> Option { + let mut urls: Vec = Vec::new(); + let mut idx = 0; + while let Some(DSeg::Extension(url)) = segs.get(idx) { + urls.push(url.clone()); + idx += 1; + } + // Keys before the first extension(), or extension() again after keys, + // are not expressible in this matcher shape. + if urls.is_empty() + || segs[idx..] + .iter() + .any(|s| !matches!(s, DSeg::Key(_) | DSeg::OfType(_))) + { + return None; + } + + let (target, rsegs) = resolve_path(node, segs)?; + let constant = constant_of(target)?; + + let tail_keys: Vec<&str> = rsegs[urls.len()..] + .iter() + .map(|r| match r { + RSeg::Key(k) => k.as_str(), + RSeg::Extension(_) => unreachable!("chain shape checked above"), + }) + .collect(); + let pattern = if tail_keys.is_empty() { + constant } else { - let mut current = node; - for segment in path.split('.') { - current = current.children.get(segment)?; - } - current + let mut map = Map::new(); + insert_nested(&mut map, &tail_keys, constant); + Value::Object(map) }; - target - .schema + + let mut matcher = json!({ "url": urls.pop()?, "pattern": pattern }); + while let Some(url) = urls.pop() { + matcher = json!({ "url": url, "extension": matcher }); + } + Some(Match { + type_: Some("extension".to_string()), + value: Some(matcher), + resolve_ref: None, + }) +} + +/// `exists` discriminators: one `{path, exists}` entry per discriminator. +/// Expected presence is read from the slice differential; a path whose +/// presence the differential doesn't pin is untranslatable. +fn build_exists_match( + node: &super::tree::Node, + discs: &[(&EdDiscriminator, Vec)], +) -> Option { + let mut entries: Vec = Vec::new(); + for (_, segs) in discs { + let (last, init) = segs.split_last()?; + let DSeg::Key(name) = last else { + return None; + }; + let (parent, rsegs) = resolve_path(node, init)?; + let expected = expected_existence(parent, name)?; + let mut path: Vec = rsegs + .iter() + .map(|r| match r { + RSeg::Key(k) => Value::String(k.clone()), + RSeg::Extension(u) => json!({ "extension": u }), + }) + .collect(); + path.push(Value::String(name.clone())); + entries.push(json!({ "path": path, "exists": expected })); + } + Some(Match { + type_: Some("exists".to_string()), + value: Some(Value::Array(entries)), + resolve_ref: None, + }) +} + +/// Did the slice differential pin `name` as present (`min >= 1`) or absent +/// (`max = 0`) under `parent`? +fn expected_existence(parent: &super::tree::Node, name: &str) -> Option { + let listed = + |list: &Option>| list.as_ref().is_some_and(|l| l.iter().any(|v| v == name)); + if listed(&parent.schema.required) { + return Some(true); + } + if listed(&parent.schema.excluded) { + return Some(false); + } + let child = parent.children.get(name)?; + if child.dead { + return Some(false); + } + if child.schema.min.is_some_and(|m| m >= 1) { + return Some(true); + } + if child.schema.max == Some(0) { + return Some(false); + } + None +} + +/// The fixed/pattern constant carried by a resolved node. +fn constant_of(node: &super::tree::Node) -> Option { + node.schema .fixed .clone() - .or_else(|| target.schema.pattern.clone()) + .or_else(|| node.schema.pattern.clone()) } -/// `insert_nested(map, "a.b", v)` → `{a: {b: v}}` (merging siblings). -fn insert_nested(map: &mut Map, path: &str, value: Value) { - let mut segments = path.split('.').peekable(); +/// `insert_nested(map, ["a", "b"], v)` → `{a: {b: v}}` (merging siblings). +fn insert_nested(map: &mut Map, keys: &[&str], value: Value) { + let (last, init) = keys.split_last().expect("non-empty key path"); let mut current = map; - while let Some(segment) = segments.next() { - if segments.peek().is_none() { - current.insert(segment.to_string(), value); - return; - } + for key in init { current = current - .entry(segment.to_string()) + .entry(key.to_string()) .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() .expect("nested pattern segment is an object"); } + current.insert(last.to_string(), value); } diff --git a/crates/fhir-validator/src/converter/tree.rs b/crates/fhir-validator/src/converter/tree.rs index 747a91dd2..742c93b68 100644 --- a/crates/fhir-validator/src/converter/tree.rs +++ b/crates/fhir-validator/src/converter/tree.rs @@ -59,6 +59,9 @@ pub(super) struct Node { pub schema: FhirSchema, pub children: IndexMap, pub slices: IndexMap, + /// `type[].profile` URLs from the ElementDefinition (used when emitting + /// `profile` slice matchers; not part of the finalized schema IR). + pub type_profiles: Vec, /// Slicing declaration from the sliced element's own ED. pub discriminators: Vec, pub slicing_rules: Option, @@ -85,6 +88,10 @@ pub(super) struct SliceNode { /// `type[0] == Extension` with a profile: compiles to the parent-level /// `extensions` sugar instead of raw slicing. pub extension_profile: Option, + /// Parent slice name when `sliceName` uses `parent/child` reslice form. + pub reslice: Option, + /// Further constrains a same-named slice from a parent schema. + pub slice_is_constraining: Option, } /// Apply one ElementDefinition at its navigated position. @@ -112,15 +119,23 @@ pub(super) fn apply(root: &mut Node, segments: &[Segment], ed: &Ed, warnings: &m return; } - // Slice definition: `identifier:mrn`. + // Slice definition: `identifier:mrn` or reslice `identifier:mrn/secondary`. if let Some(slice_name) = &last.slice { let element = node .children .entry(last.name.clone()) .or_insert_with(|| Node::new(last.name.clone())); - let slice = element.slices.entry(slice_name.clone()).or_default(); + let (store_name, reslice_of) = match slice_name.rsplit_once('/') { + Some((parent, child)) if !parent.is_empty() && !child.is_empty() => { + (slice_name.clone(), Some(parent.to_string())) + } + _ => (slice_name.clone(), None), + }; + let slice = element.slices.entry(store_name).or_default(); slice.min = ed.min; slice.max = parse_numeric_max(ed.max.as_deref()); + slice.reslice = reslice_of; + slice.slice_is_constraining = ed.slice_is_constraining; if matches!( element.element_name.as_str(), "extension" | "modifierExtension" @@ -241,6 +256,9 @@ fn apply_element_content(element: &mut Node, ed: &Ed, warnings: &mut Vec 1 => { let t = &ed.types[0]; element.schema.type_ = Some(t.effective_code()); + if !t.profile.is_empty() { + element.type_profiles = t.profile.clone(); + } if !t.target_profile.is_empty() { element.schema.refers = Some(t.target_profile.clone()); } @@ -252,13 +270,17 @@ fn apply_element_content(element: &mut Node, ed: &Ed, warnings: &mut Vec ed.path )); element.schema.type_ = Some(ed.types[0].effective_code()); + if !ed.types[0].profile.is_empty() { + element.type_profiles = ed.types[0].profile.clone(); + } } } apply_value_keywords(&mut element.schema, ed, warnings); } -/// Binding, constraints, fixed/pattern — the keywords that apply to a value -/// wherever the ED lands (plain element, choice branch, or slice schema). +/// Binding, constraints, fixed/pattern/maxLength/minValue/maxValue — keywords +/// that apply to a value wherever the ED lands (plain element, choice branch, +/// or slice schema). fn apply_value_keywords(schema: &mut FhirSchema, ed: &Ed, _warnings: &mut Vec) { if let Some(binding) = &ed.binding && let Some(value_set) = &binding.value_set @@ -269,6 +291,9 @@ fn apply_value_keywords(schema: &mut FhirSchema, ed: &Ed, _warnings: &mut Vec) -> Option>, value: &str) { } } -fn capitalize(s: &str) -> String { +pub(super) fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), diff --git a/crates/fhir-validator/src/editor.rs b/crates/fhir-validator/src/editor.rs index 17cc8a38a..b04440d50 100644 --- a/crates/fhir-validator/src/editor.rs +++ b/crates/fhir-validator/src/editor.rs @@ -392,7 +392,7 @@ pub fn addable( } let matched = items .iter() - .filter(|item| crate::engine::slicing::slice_matches(slice, item)) + .filter(|item| crate::engine::slicing::slice_matches_for(resolver, slice, item)) .count() as u64; if let Some(max) = slice.max && matched >= max @@ -713,7 +713,8 @@ pub fn slice_label( .slices .iter() .find(|(name, slice)| { - name.as_str() != "@default" && crate::engine::slicing::slice_matches(slice, item) + name.as_str() != "@default" + && crate::engine::slicing::slice_matches_for(resolver, slice, item) }) .map(|(name, _)| name.clone()) } diff --git a/crates/fhir-validator/src/effects.rs b/crates/fhir-validator/src/effects.rs index 951e31e25..7be5430e5 100644 --- a/crates/fhir-validator/src/effects.rs +++ b/crates/fhir-validator/src/effects.rs @@ -17,8 +17,10 @@ //! (never silent). An **empty** FHIRPath result counts as a pass, per //! FHIR invariant semantics (the reference validator treats empty as a //! failure — a known bug there). -//! - bindings: only `strength: required` is enforced. The coded shape is -//! the element's declared type when known, else inferred from the value +//! - bindings: `strength: required` is always enforced (error). When +//! [`EffectHandlers::check_extensible_bindings`] is set, `extensible` +//! bindings are also checked and emit warnings. The coded shape is the +//! element's declared type when known, else inferred from the value //! (`code` string / Coding / CodeableConcept, mirroring the reference). //! Provider errors surface as warning issues by default (fail-open) or //! error issues when `terminology_fail_closed` is set. @@ -141,6 +143,9 @@ pub struct EffectHandlers<'a> { pub suppress_constraints: &'a [String], /// Treat terminology-service failures as errors instead of warnings. pub terminology_fail_closed: bool, + /// When true, also check `extensible`-strength bindings and emit + /// warning-severity issues on failure (required bindings stay errors). + pub check_extensible_bindings: bool, } /// Execute the deferred obligations, appending issues to `errors`. @@ -256,9 +261,12 @@ async fn execute_bindings( else { continue; }; - if binding.strength.as_deref() != Some("required") { - continue; - } + let strength = binding.strength.as_deref().unwrap_or("required"); + let issue_severity = match strength { + "required" => Severity::Error, + "extensible" if handlers.check_extensible_bindings => Severity::Warning, + _ => continue, + }; let Some(coded) = coded_value(value, type_hint.as_deref()) else { continue; // shape not coded (e.g. null gap) — structural checks own it }; @@ -271,6 +279,7 @@ async fn execute_bindings( path.clone(), messages::terminology_binding(value, &binding.value_set), ) + .with_severity(issue_severity) .with_extra( "binding", serde_json::to_value(binding).expect("binding serializes"), diff --git a/crates/fhir-validator/src/engine/errors.rs b/crates/fhir-validator/src/engine/errors.rs index 152cdaaf2..efc02c62f 100644 --- a/crates/fhir-validator/src/engine/errors.rs +++ b/crates/fhir-validator/src/engine/errors.rs @@ -49,6 +49,14 @@ pub enum ErrorKind { // ------------------------------------------------------------------ /// A primitive value failed its type-class or regex check. PrimitiveValue, + /// String longer than `maxLength`. + MaxLength, + /// Value below `minValue`. + MinValue, + /// Value above `maxValue`. + MaxValue, + /// `Reference.reference` target type not in `refers`. + ReferenceTarget, /// An array item matched no slice under `rules: closed`, or an unmatched /// item preceded matched items under `rules: openAtEnd`. SliceUnmatched, @@ -60,6 +68,8 @@ pub enum ErrorKind { /// A profile named in `meta.profile` or the caller's profile list could /// not be resolved. UnknownProfile, + /// QuestionnaireResponse failed Questionnaire-driven checks. + Questionnaire, } /// Issue severity. Internal only — deliberately not serialized, so the @@ -244,6 +254,37 @@ pub(crate) fn msg_primitive_regex(type_name: &str, value: &str) -> String { format!("value '{value}' is not a valid {type_name}") } +/// [helios] `maxLength` exceeded. +pub(crate) fn msg_max_length(max: u64, actual: usize) -> String { + format!("string length {actual} exceeds maxLength {max}") +} + +/// [helios] value below `minValue`. +pub(crate) fn msg_min_value(min: &Value, actual: &Value) -> String { + format!( + "value '{}' is less than minValue '{}'", + render_value(actual), + render_value(min) + ) +} + +/// [helios] value above `maxValue`. +pub(crate) fn msg_max_value(max: &Value, actual: &Value) -> String { + format!( + "value '{}' is greater than maxValue '{}'", + render_value(actual), + render_value(max) + ) +} + +/// [helios] Reference target type not allowed by `refers`. +pub(crate) fn msg_reference_target(actual: &str, allowed: &[String]) -> String { + format!( + "reference target type '{actual}' is not in refers [{}]", + allowed.join(", ") + ) +} + /// [reference] `FHIRPath constraint {id} error: {human}` — the wording of /// the reference validator, including its fallback when `human` is absent. pub(crate) fn msg_fhirpath_constraint(id: &str, human: Option<&str>) -> String { diff --git a/crates/fhir-validator/src/engine/mod.rs b/crates/fhir-validator/src/engine/mod.rs index 9dc0b8970..6ad8de75e 100644 --- a/crates/fhir-validator/src/engine/mod.rs +++ b/crates/fhir-validator/src/engine/mod.rs @@ -56,6 +56,9 @@ pub struct ValidationOptions { pub use_meta_profiles: bool, /// What to do when a profile reference cannot be resolved. pub unknown_profile: UnknownProfilePolicy, + /// When true, enforce `refers` (Reference target resource-type) checks. + /// Off by default to preserve upstream conformance-suite parity. + pub enforce_refers: bool, } impl Default for ValidationOptions { @@ -64,6 +67,7 @@ impl Default for ValidationOptions { profiles: Vec::new(), use_meta_profiles: true, unknown_profile: UnknownProfilePolicy::default(), + enforce_refers: false, } } } diff --git a/crates/fhir-validator/src/engine/slicing.rs b/crates/fhir-validator/src/engine/slicing.rs index 5bdd760df..e1d7fef1b 100644 --- a/crates/fhir-validator/src/engine/slicing.rs +++ b/crates/fhir-validator/src/engine/slicing.rs @@ -19,11 +19,16 @@ //! enforced (pinned by `tests/fixtures/extended/slicing_rules.json`), and a //! `max: 0` prohibited slice is enforced (the reference skips falsy bounds). //! -//! Match types other than `pattern` (`type`, `profile`, `binding`, -//! `resolve-ref`) are deliberately not evaluated yet — they arrive in a later -//! phase alongside the converter's discriminator translation. A slice whose -//! matcher we cannot evaluate matches nothing, and a slice with no `match` -//! at all (a constraining slice) also matches nothing. +//! Match types: `pattern` (partial deep equality), `type` (JSON/FHIR type +//! codes), `profile` (meta.profile claim or resolvable schema type), +//! `binding` (coded value equals the match payload when it is a Coding / +//! code string — ValueSet membership is deferred to the effects pass when +//! the payload is a canonical ValueSet URL and no inline code is present), +//! `exists` (presence/absence of each `{path, exists}` entry, with +//! `{extension: url}` path steps and choice-key prefixes), and `extension` +//! (a `{url, pattern | extension}` chain matched against extension arrays +//! by containment). `resolve-ref` remains unevaluated. A slice with no +//! `match` matches nothing. use super::errors::{self, ErrorKind}; use super::walk::{SchemaSet, WalkCtx, add_schemas_to_set, is_partial_match, validate_node}; @@ -67,7 +72,7 @@ pub(super) fn validate_slices( if name == DEFAULT_SLICE { continue; } - if slice_matches(slice, item) { + if slice_matches_with_reslice(ctx, &slicing, name, slice, item) { *counters.get_mut(name).expect("counter exists") += 1; item_matches[index].push(name.clone()); } @@ -207,77 +212,349 @@ pub(super) fn validate_slices( consumed } -/// Does an item belong to a slice? Only `pattern` matching is evaluated -/// today; a missing `match` (constraining slice) matches nothing, and a -/// `match` with no `value` matches everything (lodash `_.isMatch` semantics -/// for an empty source). -pub(crate) fn slice_matches(slice: &Slice, item: &Value) -> bool { - if let Some(match_) = &slice.match_ { - return match match_.value.as_ref() { - Some(pattern) => is_partial_match(item, pattern), - None => true, +/// Reslice-aware match: a child slice `parent/child` only matches when the +/// parent slice also matches the item. Constraining slices without their own +/// matcher inherit the same-named parent's matcher when present. +fn slice_matches_with_reslice( + ctx: &WalkCtx<'_>, + slicing: &Slicing, + _name: &str, + slice: &Slice, + item: &Value, +) -> bool { + if let Some(parent) = &slice.reslice { + let Some(parent_slice) = slicing.slices.get(parent) else { + return false; }; + if !slice_matches(ctx, parent_slice, item) { + return false; + } + // Child may add its own matcher on top of the parent. + if slice.match_.is_some() { + return slice_matches(ctx, slice, item); + } + return true; + } + if slice.match_.is_none() && slice.slice_is_constraining == Some(true) { + // Inherit matcher from a same-named non-constraining sibling/parent + // declaration if present in this layer (rare); otherwise match nothing. + return false; } - // The converter carries a pattern/value discriminator as the slice - // schema's pattern (or fixed) keyword rather than an explicit match. - if let Some(schema) = &slice.schema { - if let Some(pattern) = &schema.pattern { - return is_partial_match(item, pattern); + slice_matches(ctx, slice, item) +} + +/// Slice matching for a caller that holds a resolver but no in-flight walk. +/// +/// The guided-form editor (`crate::editor`) asks "which slice does this item +/// belong to?" while rendering, outside any validation run. It gets the same +/// matcher the walk uses — the two must agree, or the form would offer an add +/// the validator then rejects. +pub(crate) fn slice_matches_for( + resolver: &dyn crate::SchemaResolver, + slice: &Slice, + item: &Value, +) -> bool { + slice_matches(&WalkCtx::read_only(resolver), slice, item) +} + +/// Does an item belong to a slice? +/// +/// A missing `match` (constraining slice) matches nothing. A `match` with no +/// `value` matches everything (lodash `_.isMatch` semantics for an empty +/// source). `type_` defaults to `pattern` when absent. +fn slice_matches(ctx: &WalkCtx<'_>, slice: &Slice, item: &Value) -> bool { + let Some(match_) = &slice.match_ else { + // No explicit `match`: the converter can instead carry the + // pattern/value discriminator as the slice schema's `pattern` (or + // `fixed`) keyword. Falling back to it here is what makes a + // converter-produced slice discriminate at all. + if let Some(schema) = &slice.schema { + if let Some(pattern) = &schema.pattern { + return is_partial_match(item, pattern); + } + if let Some(fixed) = &schema.fixed { + return is_partial_match(item, fixed); + } } - if let Some(fixed) = &schema.fixed { - return is_partial_match(item, fixed); + return false; + }; + let Some(value) = match_.value.as_ref() else { + return true; + }; + match match_.type_.as_deref().unwrap_or("pattern") { + "pattern" => is_partial_match(item, value), + "type" => { + let Some(expected) = value.as_str() else { + return false; + }; + json_fhir_types(item).iter().any(|t| t == expected) + } + "profile" => { + let Some(profile) = value.as_str() else { + return false; + }; + profile_matches(ctx, item, profile) + } + "binding" => binding_matches(item, value), + "exists" => exists_matches(item, value), + "extension" => extension_matches(item, value), + _ => false, + } +} + +/// `exists` matcher: every `{path, exists}` entry must hold on the item. +fn exists_matches(item: &Value, spec: &Value) -> bool { + let Some(entries) = spec.as_array() else { + return false; + }; + entries.iter().all(|entry| { + let expected = entry.get("exists").and_then(Value::as_bool).unwrap_or(true); + entry + .get("path") + .and_then(Value::as_array) + .is_some_and(|path| path_exists(item, path) == expected) + }) +} + +/// Walk one exists-path over instance JSON. Steps are element keys (with a +/// choice-prefix fallback: `value` also reaches `valueQuantity`) or +/// `{extension: url}` selections; arrays fan out existentially. +fn path_exists(current: &Value, segs: &[Value]) -> bool { + let Some(seg) = segs.first() else { + return !current.is_null(); + }; + let rest = &segs[1..]; + match current { + Value::Array(items) => items.iter().any(|item| path_exists(item, segs)), + Value::Object(map) => { + if let Some(key) = seg.as_str() { + if map.get(key).is_some_and(|v| path_exists(v, rest)) { + return true; + } + map.iter().any(|(k, v)| { + k.strip_prefix(key) + .and_then(|s| s.chars().next()) + .is_some_and(char::is_uppercase) + && path_exists(v, rest) + }) + } else if let Some(url) = seg.get("extension").and_then(Value::as_str) { + map.get("extension") + .and_then(Value::as_array) + .is_some_and(|exts| { + exts.iter().any(|e| { + e.get("url").and_then(Value::as_str) == Some(url) + && path_exists(e, rest) + }) + }) + } else { + false + } + } + _ => false, + } +} + +/// `extension` matcher: some element of the item's `extension` array has the +/// matcher's `url` and satisfies its `pattern` (partial match) or its nested +/// `extension` matcher (chained complex extensions). +fn extension_matches(item: &Value, matcher: &Value) -> bool { + let Some(url) = matcher.get("url").and_then(Value::as_str) else { + return false; + }; + let Some(exts) = item.get("extension").and_then(Value::as_array) else { + return false; + }; + exts.iter().any(|e| { + if e.get("url").and_then(Value::as_str) != Some(url) { + return false; + } + if let Some(nested) = matcher.get("extension") { + return extension_matches(e, nested); + } + match matcher.get("pattern") { + Some(pattern) => is_partial_match(e, pattern), + None => true, + } + }) +} + +/// Infer FHIR type codes from a JSON value (resourceType, primitives, Coding). +fn json_fhir_types(item: &Value) -> Vec { + match item { + Value::String(_) => vec![ + "string".into(), + "uri".into(), + "url".into(), + "canonical".into(), + "code".into(), + "id".into(), + "markdown".into(), + "oid".into(), + "uuid".into(), + "base64Binary".into(), + "date".into(), + "dateTime".into(), + "instant".into(), + "time".into(), + ], + Value::Bool(_) => vec!["boolean".into()], + Value::Number(n) => { + if n.is_i64() || n.is_u64() { + vec![ + "integer".into(), + "positiveInt".into(), + "unsignedInt".into(), + "decimal".into(), + ] + } else { + vec!["decimal".into()] + } + } + Value::Object(map) => { + if let Some(rt) = map.get("resourceType").and_then(Value::as_str) { + return vec![rt.to_string()]; + } + if map.contains_key("system") && map.contains_key("code") { + return vec!["Coding".into()]; + } + if map.contains_key("coding") || (map.contains_key("text") && map.len() <= 2) { + return vec!["CodeableConcept".into()]; + } + if map.contains_key("reference") || map.contains_key("identifier") { + return vec!["Reference".into()]; + } + if map.contains_key("value") && (map.contains_key("unit") || map.contains_key("system")) + { + return vec!["Quantity".into()]; + } + if map.contains_key("url") { + return vec!["Extension".into()]; + } + Vec::new() + } + Value::Array(_) | Value::Null => Vec::new(), + } +} + +fn profile_matches(ctx: &WalkCtx<'_>, item: &Value, profile: &str) -> bool { + if let Some(profiles) = item + .get("meta") + .and_then(|m| m.get("profile")) + .and_then(Value::as_array) + && profiles.iter().any(|p| p.as_str() == Some(profile)) + { + return true; + } + // Extension slices: match by url when the profile canonical is the + // extension URL (common IG pattern). + if let Some(url) = item.get("url").and_then(Value::as_str) + && url == profile + { + return true; + } + // Resolvable profile whose `type` matches the item's resourceType / JSON type. + if let Some(schema) = ctx.resolver.resolve(profile) { + if let Some(ty) = schema.type_.as_deref() { + if json_fhir_types(item).iter().any(|t| t == ty) { + return true; + } + if item.get("resourceType").and_then(Value::as_str) == Some(ty) { + return true; + } + } + if let Some(name) = schema.name.as_deref() + && item.get("resourceType").and_then(Value::as_str) == Some(name) + { + return true; } } false } +/// Binding discriminator: if `expected` is a string, accept when the item is +/// that code, a Coding with that code, or a CodeableConcept containing it. +/// Full ValueSet expansion is intentionally not done here. +fn binding_matches(item: &Value, expected: &Value) -> bool { + let Some(needle) = expected.as_str() else { + return is_partial_match(item, expected); + }; + // Canonical ValueSet URL — only match when the instance literally carries + // that URL (rare); otherwise leave unmatched (slice stays inactive for + // ValueSet-based binding discriminators without inline codes). + if needle.contains('/') { + return item.as_str() == Some(needle) + || item.get("system").and_then(Value::as_str) == Some(needle); + } + match item { + Value::String(s) => s == needle, + Value::Object(map) => { + if map.get("code").and_then(Value::as_str) == Some(needle) { + return true; + } + if let Some(coding) = map.get("coding").and_then(Value::as_array) { + return coding + .iter() + .any(|c| c.get("code").and_then(Value::as_str) == Some(needle)); + } + false + } + _ => false, + } +} + #[cfg(test)] mod slice_match_tests { use super::*; + use crate::SchemaRegistry; use serde_json::json; fn slice(v: serde_json::Value) -> Slice { serde_json::from_value(v).expect("slice") } + /// These cases exercise only the discriminator arms that never consult the + /// resolver, so an empty registry is enough context. + fn matches(s: &Slice, item: &serde_json::Value) -> bool { + let registry = SchemaRegistry::new(); + let ctx = WalkCtx::read_only(®istry); + slice_matches(&ctx, s, item) + } + #[test] fn an_explicit_match_value_is_a_partial_match() { let s = slice(json!({ "match": { "type": "pattern", "value": { "system": "http://x" } } })); - assert!(slice_matches( - &s, - &json!({ "system": "http://x", "value": "1" }) - )); - assert!(!slice_matches(&s, &json!({ "system": "http://y" }))); + assert!(matches(&s, &json!({ "system": "http://x", "value": "1" }))); + assert!(!matches(&s, &json!({ "system": "http://y" }))); } #[test] fn a_match_without_a_value_matches_everything() { let s = slice(json!({ "match": { "type": "pattern" } })); - assert!(slice_matches(&s, &json!({ "anything": true }))); + assert!(matches(&s, &json!({ "anything": true }))); } #[test] fn a_schema_pattern_stands_in_for_the_match() { let s = slice(json!({ "schema": { "pattern": { "system": "http://x" } } })); - assert!(slice_matches(&s, &json!({ "system": "http://x" }))); - assert!(!slice_matches(&s, &json!({ "system": "http://y" }))); + assert!(matches(&s, &json!({ "system": "http://x" }))); + assert!(!matches(&s, &json!({ "system": "http://y" }))); } #[test] fn a_schema_fixed_stands_in_for_the_match() { let s = slice(json!({ "schema": { "fixed": { "system": "http://x" } } })); - assert!(slice_matches(&s, &json!({ "system": "http://x" }))); + assert!(matches(&s, &json!({ "system": "http://x" }))); } #[test] fn no_discriminator_at_all_matches_nothing() { let s = slice(json!({ "min": 1 })); - assert!(!slice_matches(&s, &json!({ "system": "http://x" }))); + assert!(!matches(&s, &json!({ "system": "http://x" }))); } #[test] fn a_schema_with_neither_pattern_nor_fixed_matches_nothing() { let s = slice(json!({ "schema": { "type": "Identifier" } })); - assert!(!slice_matches(&s, &json!({ "system": "http://x" }))); + assert!(!matches(&s, &json!({ "system": "http://x" }))); } } diff --git a/crates/fhir-validator/src/engine/walk.rs b/crates/fhir-validator/src/engine/walk.rs index 567d39e31..e23ed54df 100644 --- a/crates/fhir-validator/src/engine/walk.rs +++ b/crates/fhir-validator/src/engine/walk.rs @@ -81,10 +81,30 @@ impl SchemaSet { } pub(super) struct WalkCtx<'a> { - resolver: &'a dyn SchemaResolver, + pub(super) resolver: &'a dyn SchemaResolver, errors: Vec, deferred: Vec, pub(super) path: PathTracker, + enforce_refers: bool, +} + +impl<'a> WalkCtx<'a> { + /// A context that carries nothing but the resolver. + /// + /// For callers that need the walk's *pure* helpers — slice matching, above + /// all — without an in-flight validation: the guided-form editor, and unit + /// tests. Errors and deferred effects pushed onto it are discarded, so only + /// pass it to helpers that take `&self`. The struct's other fields are + /// private to this module, so a sibling module cannot build one directly. + pub(super) fn read_only(resolver: &'a dyn SchemaResolver) -> Self { + Self { + resolver, + errors: Vec::new(), + deferred: Vec::new(), + path: PathTracker::new(""), + enforce_refers: false, + } + } } impl WalkCtx<'_> { @@ -118,6 +138,7 @@ pub(super) fn validate( errors: Vec::new(), deferred: Vec::new(), path: PathTracker::new(resource_type), + enforce_refers: opts.enforce_refers, }; // Root schema-set: resourceType, then meta.profile claims, then @@ -344,6 +365,31 @@ fn eval_validators(ctx: &mut WalkCtx<'_>, set: &SchemaSet, data: &Value) { errors::msg_pattern_value(pattern, data), ); } + if let Some(max_length) = schema.max_length + && let Some(s) = data.as_str() + && (s.chars().count() as u64) > max_length + { + ctx.error( + ErrorKind::MaxLength, + errors::msg_max_length(max_length, s.chars().count()), + ); + } + if let Some(min_value) = &schema.min_value + && compare_ordered(data, min_value) == Some(std::cmp::Ordering::Less) + { + ctx.error(ErrorKind::MinValue, errors::msg_min_value(min_value, data)); + } + if let Some(max_value) = &schema.max_value + && compare_ordered(data, max_value) == Some(std::cmp::Ordering::Greater) + { + ctx.error(ErrorKind::MaxValue, errors::msg_max_value(max_value, data)); + } + if ctx.enforce_refers + && let Some(refers) = &schema.refers + && !refers.is_empty() + { + check_refers(ctx, refers, data); + } if let Some(constraints) = &schema.constraints { let path = ctx.path.render_dotted(); for (id, c) in constraints { @@ -718,6 +764,9 @@ fn merge_extension_schema(entry: &FhirSchema, resolved: Option<&FhirSchema>) -> choice_of, fixed, pattern, + max_length, + min_value, + max_value, binding, constraints, refers, @@ -733,6 +782,68 @@ fn merge_extension_schema(entry: &FhirSchema, resolved: Option<&FhirSchema>) -> out } +/// Compare ordered scalar values (numbers / ISO-ish date strings). +fn compare_ordered(actual: &Value, bound: &Value) -> Option { + match (actual, bound) { + (Value::Number(a), Value::Number(b)) => { + let af = a.as_f64()?; + let bf = b.as_f64()?; + af.partial_cmp(&bf) + } + (Value::String(a), Value::String(b)) => Some(a.cmp(b)), + _ => None, + } +} + +/// Enforce `refers` against `Reference.reference` resource-type prefix. +fn check_refers(ctx: &mut WalkCtx<'_>, refers: &[String], data: &Value) { + let Some(reference) = data + .as_object() + .and_then(|o| o.get("reference")) + .and_then(Value::as_str) + else { + return; + }; + // Absolute URLs / fragments / urns: skip type extraction. + if reference.starts_with("http://") + || reference.starts_with("https://") + || reference.starts_with("urn:") + || reference.starts_with('#') + { + return; + } + let type_name = reference + .split_once('/') + .map(|(ty, _)| ty) + .unwrap_or(reference); + if type_name.is_empty() || type_name.contains(':') { + return; + } + let allowed: Vec = refers + .iter() + .map(|r| { + // Canonical profile URLs → last path segment; bare types kept. + r.rsplit('/').next().unwrap_or(r).to_string() + }) + .collect(); + // Profile URLs in refers do not yield a resource type — only bare type + // codes (and trailing segments that look like resource types) count. + let type_codes: Vec = allowed + .iter() + .filter(|a| a.chars().next().is_some_and(|c| c.is_ascii_uppercase())) + .cloned() + .collect(); + if type_codes.is_empty() { + return; + } + if !type_codes.iter().any(|t| t == type_name) { + ctx.error( + ErrorKind::ReferenceTarget, + errors::msg_reference_target(type_name, &type_codes), + ); + } +} + /// Lodash-style `_.isMatch`: partial deep match. Every key present in /// `pattern` must exist in `data` and match recursively; extra data keys are /// permitted. Arrays match index-wise as a prefix; scalars by equality. diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs index dc3d363d8..bc9d1f39d 100644 --- a/crates/fhir-validator/src/lib.rs +++ b/crates/fhir-validator/src/lib.rs @@ -6,6 +6,27 @@ //! StructureDefinitions, validated via **cooperative schema sets** rather //! than snapshot flattening. //! +//! ## Overview +//! +//! | Concern | Module | +//! |---------|--------| +//! | StructureDefinition → schema | [`converter`] | +//! | Structural walk (cardinality, slices, fixed/pattern, …) | [`engine`] | +//! | FHIRPath constraints + terminology bindings | [`effects`], [`fhirpath_effects`] | +//! | Embedded core packs (R4–R6) | [`packs`], [`terminology`] | +//! | FHIR NPM / IG package overlays | [`packages`] | +//! | QuestionnaireResponse vs Questionnaire | [`questionnaire`] | +//! | Authoring projection (“what can I add?”) | [`editor`] | +//! +//! The engine walks raw `serde_json::Value` — deliberately, since the typed +//! `helios-fhir` models deserialize leniently and cannot surface unknown +//! elements. Structural validation is pure and synchronous; FHIRPath +//! constraints and terminology bindings are collected as [`Deferred`] +//! obligations for an async effects pass. +//! +//! Resolver layering (earlier wins): tenant stored StructureDefinitions → +//! package layers (filtered by `fhirVersions`) → embedded core pack. +//! //! ## Quick start //! //! ``` @@ -31,41 +52,42 @@ //! assert!(outcome.errors.is_empty()); //! ``` //! -//! The engine walks raw `serde_json::Value` — deliberately, since the typed -//! `helios-fhir` models deserialize leniently and cannot surface unknown -//! elements. Structural validation is pure and synchronous; FHIRPath -//! constraints and terminology bindings are collected as [`Deferred`] -//! obligations for an async effects pass. -//! //! The behavioral contract is the vendored FHIR Schema conformance suite in //! `tests/fixtures/upstream/` (exact ordered error matching), plus Helios -//! extended fixtures in `tests/fixtures/extended/`. +//! extended fixtures in `tests/fixtures/extended/`. Package materialization +//! is documented in [`docs/packages.md`](../docs/packages.md). //! -//! ## Current limitations (hardening backlog) +//! ## Current limitations //! -//! - Slice matchers: only `pattern` matching is evaluated. `type`, -//! `profile`, `binding`, and `resolve-ref` matchers are parsed but inert -//! (such slices match nothing and never enforce a minimum; the converter -//! emits a warning when it cannot build a pattern match). -//! - `refers` (reference target types) is carried but not enforced. -//! - `extensible`-strength bindings are never checked (only `required`, -//! per the FHIR Schema spec); a warning mode may come later. +//! - Slice matchers: `pattern`, `type`, `profile`, `binding`, `exists`, and +//! `extension` (paths traversing `extension('url')`) are evaluated; +//! reslices (`parent/child`) are scoped to the parent match. Discriminator +//! paths using `resolve()` remain unsupported (need an instance graph), as +//! does `resolve-ref`. Binding discriminators that name a ValueSet +//! canonical do not expand it at mark time. +//! - `refers` (reference target types) is enforced only when +//! [`ValidationOptions::enforce_refers`] is set (off by default for +//! conformance-suite parity). Profile-target resolution is not performed. +//! - `extensible`-strength bindings emit warnings only when +//! [`EffectHandlers::check_extensible_bindings`] is set; `preferred` / +//! `example` are never checked. //! - Constraint evaluation resolves `%resource`/`%rootResource` to the root //! resource and evaluates via `path.all(expr)`, so invariants relying on //! nested-resource `%resource` semantics can misfire (helios-fhirpath //! limitation; see `fhirpath_effects`). -//! - Schema sets are assembled per node without cross-resource memoization; -//! structural validation of a typical Patient measures ~300µs in debug -//! builds (see `tests/pack_smoke.rs`), so this has not been worth it yet. +//! - Non-goals: XHTML well-formedness, Bundle `fullUrl` uniqueness rules. //! - Core extension definitions (`extension-definitions.json`) are not in //! the vendored spec bundles, so pack profiles whose `extensions` sugar -//! references core extension URLs report `unknown-schema` when exercised. +//! references core extension URLs report `unknown-schema` when exercised +//! without an IG package that provides them. pub mod converter; pub mod editor; pub mod effects; pub mod engine; +pub mod packages; pub mod packs; +pub mod questionnaire; pub mod resolver; pub mod schema; pub mod terminology; @@ -85,6 +107,13 @@ pub use engine::{ ErrorKind, Severity, SyncOutcome, UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, }; +pub use packages::{ + MaterializeReport, PackageCache, PackageError, PackageId, PackageManifest, PackageRef, + ResolvedPackage, ScannedPackage, ensure_package_path, manifest_supports_fhir_version, + materialize_package, materialize_package_layers, materialize_package_layers_by_version, + materialize_tgz, resolve_packages, scan_package_dir, +}; +pub use questionnaire::validate_questionnaire_response; pub use resolver::{CompositeResolver, SchemaRegistry, SchemaResolver}; pub use schema::{Binding, Constraint, FhirSchema, Match, Slice, Slicing}; pub use terminology::{CoreTerminology, core_terminology}; diff --git a/crates/fhir-validator/src/packages/cache.rs b/crates/fhir-validator/src/packages/cache.rs new file mode 100644 index 000000000..ea49a4d95 --- /dev/null +++ b/crates/fhir-validator/src/packages/cache.rs @@ -0,0 +1,281 @@ +use crate::packages::error::PackageError; +use crate::packages::manifest::{PackageId, PackageManifest}; +use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use tar::Archive; + +/// Curated on-disk FHIR NPM package cache. +/// +/// Layout: `{root}/{name}/{version}/` with `package.json` at that root. +/// Resolution is offline — [`Self::get`] fails if the package is absent. +#[derive(Debug, Clone)] +pub struct PackageCache { + root: PathBuf, +} + +impl PackageCache { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Directory for `id`, whether or not it exists yet. + pub fn package_dir(&self, id: &PackageId) -> PathBuf { + self.root.join(&id.name).join(&id.version) + } + + /// Return the expanded package directory if present and valid. + pub fn get(&self, id: &PackageId) -> Result { + let dir = self.package_dir(id); + let manifest_path = dir.join("package.json"); + if !manifest_path.is_file() { + return Err(PackageError::NotInCache { + name: id.name.clone(), + version: id.version.clone(), + }); + } + let manifest = PackageManifest::load(&manifest_path)?; + if manifest.name != id.name || manifest.version != id.version { + return Err(PackageError::Manifest(format!( + "cache entry {} has package.json id {}@{} (expected {id})", + dir.display(), + manifest.name, + manifest.version + ))); + } + Ok(dir) + } + + /// Extract a FHIR NPM `.tgz` into the cache. Returns the package id from + /// `package.json`. Writes a `.sha256` sidecar of the source archive. + pub fn ensure_from_tgz(&self, tgz: &Path) -> Result { + if !tgz.is_file() { + return Err(PackageError::Invalid(format!( + "tarball not found: {}", + tgz.display() + ))); + } + + let sha = hash_file(tgz)?; + let staging = self.root.join(".staging").join(&sha); + if staging.exists() { + fs::remove_dir_all(&staging).map_err(|e| PackageError::io(&staging, e))?; + } + fs::create_dir_all(&staging).map_err(|e| PackageError::io(&staging, e))?; + + extract_tgz(tgz, &staging)?; + let package_root = find_package_root(&staging)?; + let manifest = PackageManifest::load(&package_root.join("package.json"))?; + let id = manifest.id(); + let dest = self.package_dir(&id); + + if dest.exists() { + fs::remove_dir_all(&dest).map_err(|e| PackageError::io(&dest, e))?; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| PackageError::io(parent, e))?; + } + // Move contents of package_root into dest. + fs::rename(&package_root, &dest).or_else(|_| { + copy_dir_all(&package_root, &dest)?; + fs::remove_dir_all(&package_root).map_err(|e| PackageError::io(&package_root, e)) + })?; + + let _ = fs::remove_dir_all(self.root.join(".staging")); + + let sidecar = dest.join(".sha256"); + fs::write(&sidecar, format!("{sha}\n")).map_err(|e| PackageError::io(&sidecar, e))?; + + Ok(id) + } + + /// Copy an already-expanded package directory (must contain `package.json`) + /// into the cache. + pub fn ensure_from_dir(&self, dir: &Path) -> Result { + let package_root = find_package_root(dir)?; + let manifest = PackageManifest::load(&package_root.join("package.json"))?; + let id = manifest.id(); + let dest = self.package_dir(&id); + if dest.exists() { + fs::remove_dir_all(&dest).map_err(|e| PackageError::io(&dest, e))?; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| PackageError::io(parent, e))?; + } + copy_dir_all(&package_root, &dest)?; + Ok(id) + } + + /// Install a package from a local path into the cache. + /// + /// Accepts: + /// - a FHIR NPM `.tgz` / `.tar.gz` file + /// - an expanded package directory (`package.json`, or `package/package.json`) + /// - an IG **publisher `output/`** directory: prefers `package.tgz`, else a + /// single `*.tgz`, else `package/` if present + /// + /// Note: `.staging/` under the cache root is only a temporary unpack area + /// used while installing a tarball — it is **not** a package source. + pub fn ensure_from_path(&self, path: &Path) -> Result { + if path.is_file() { + if is_tarball(path) { + return self.ensure_from_tgz(path); + } + return Err(PackageError::Invalid(format!( + "not a FHIR package tarball: {} (expected .tgz / .tar.gz)", + path.display() + ))); + } + if !path.is_dir() { + return Err(PackageError::Invalid(format!( + "package source not found: {}", + path.display() + ))); + } + + // Expanded package (NPM layout or flat). + if path.join("package.json").is_file() + || path.join("package").join("package.json").is_file() + { + return self.ensure_from_dir(path); + } + + // IG publisher `output/`: prefer canonical package.tgz. + let package_tgz = path.join("package.tgz"); + if package_tgz.is_file() { + return self.ensure_from_tgz(&package_tgz); + } + + let mut tarballs = list_tarballs(path)?; + tarballs.sort(); + match tarballs.as_slice() { + [only] => self.ensure_from_tgz(only), + [] => Err(PackageError::Invalid(format!( + "directory {} is not a FHIR package (no package.json / package/) \ + and contains no .tgz — for IG publisher output, pass \ + output/package.tgz or output/.tgz, not the whole HTML tree", + path.display() + ))), + many => Err(PackageError::Invalid(format!( + "directory {} has multiple package tarballs ({}); pass one explicitly \ + (prefer package.tgz)", + path.display(), + many.iter() + .filter_map(|p| p.file_name().and_then(|n| n.to_str())) + .collect::>() + .join(", ") + ))), + } + } +} + +fn is_tarball(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + name.ends_with(".tgz") || name.ends_with(".tar.gz") +} + +fn list_tarballs(dir: &Path) -> Result, PackageError> { + let mut out = Vec::new(); + for ent in fs::read_dir(dir).map_err(|e| PackageError::io(dir, e))? { + let ent = ent.map_err(|e| PackageError::io(dir, e))?; + let p = ent.path(); + if p.is_file() && is_tarball(&p) { + out.push(p); + } + } + Ok(out) +} + +fn hash_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|e| PackageError::io(path, e))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf).map_err(|e| PackageError::io(path, e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn extract_tgz(tgz: &Path, dest: &Path) -> Result<(), PackageError> { + let file = File::open(tgz).map_err(|e| PackageError::io(tgz, e))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + archive + .unpack(dest) + .map_err(|e| PackageError::Invalid(format!("failed to extract {}: {e}", tgz.display())))?; + Ok(()) +} + +/// Prefer `dir/package/package.json` (NPM layout), else `dir/package.json`. +fn find_package_root(dir: &Path) -> Result { + let nested = dir.join("package"); + if nested.join("package.json").is_file() { + return Ok(nested); + } + if dir.join("package.json").is_file() { + return Ok(dir.to_path_buf()); + } + // After unpack, the archive may have a single top-level folder. + if dir.is_dir() { + let mut children = fs::read_dir(dir) + .map_err(|e| PackageError::io(dir, e))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect::>(); + if children.len() == 1 { + let child = children.pop().expect("one child"); + if child.join("package.json").is_file() { + return Ok(child); + } + if child.join("package").join("package.json").is_file() { + return Ok(child.join("package")); + } + } + } + Err(PackageError::Manifest(format!( + "no package.json under {}", + dir.display() + ))) +} + +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PackageError> { + fs::create_dir_all(dst).map_err(|e| PackageError::io(dst, e))?; + for entry in fs::read_dir(src).map_err(|e| PackageError::io(src, e))? { + let entry = entry.map_err(|e| PackageError::io(src, e))?; + let ty = entry.file_type().map_err(|e| PackageError::io(src, e))?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&from, &to)?; + } else { + fs::copy(&from, &to).map_err(|e| PackageError::io(&from, e))?; + } + } + Ok(()) +} + +/// Used by tests / materialize helpers when writing small files. +#[allow(dead_code)] +pub(crate) fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PackageError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| PackageError::io(parent, e))?; + } + let mut f = File::create(path).map_err(|e| PackageError::io(path, e))?; + f.write_all(bytes).map_err(|e| PackageError::io(path, e))?; + Ok(()) +} diff --git a/crates/fhir-validator/src/packages/error.rs b/crates/fhir-validator/src/packages/error.rs new file mode 100644 index 000000000..c7aafffd1 --- /dev/null +++ b/crates/fhir-validator/src/packages/error.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Errors from package cache, resolution, or materialization. +#[derive(Debug, Error)] +pub enum PackageError { + /// I/O failure while reading or writing the cache. + #[error("package I/O error at {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + /// Archive extract or JSON parse failure. + #[error("{0}")] + Invalid(String), + /// Requested package is not present in the cache. + #[error("package {name}@{version} not found in cache (offline resolve)")] + NotInCache { name: String, version: String }, + /// `package.json` missing or malformed. + #[error("{0}")] + Manifest(String), + /// Dependency graph problem (missing dep, cycle, FHIR version mismatch). + #[error("{0}")] + Resolve(String), + /// StructureDefinition conversion failed hard enough to abort. + #[error("{0}")] + Convert(String), +} + +impl PackageError { + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} diff --git a/crates/fhir-validator/src/packages/manifest.rs b/crates/fhir-validator/src/packages/manifest.rs new file mode 100644 index 000000000..33453ffc8 --- /dev/null +++ b/crates/fhir-validator/src/packages/manifest.rs @@ -0,0 +1,105 @@ +use crate::packages::error::PackageError; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::path::Path; + +/// A pinned FHIR NPM package identity (`name@version`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PackageId { + pub name: String, + pub version: String, +} + +impl PackageId { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +impl fmt::Display for PackageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}@{}", self.name, self.version) + } +} + +/// Operator-facing package reference — same as [`PackageId`] for v1 +/// (exact versions only; no ranges). +pub type PackageRef = PackageId; + +impl PackageRef { + /// Parse `name@version`. Uses `rsplit_once('@')` so names may contain dots. + pub fn parse(s: &str) -> Result { + let s = s.trim(); + let (name, version) = s.rsplit_once('@').ok_or_else(|| { + PackageError::Invalid(format!( + "package ref '{s}' must be name@version (exact version)" + )) + })?; + if name.is_empty() || version.is_empty() { + return Err(PackageError::Invalid(format!( + "package ref '{s}' has empty name or version" + ))); + } + Ok(Self::new(name, version)) + } +} + +/// Subset of FHIR NPM `package.json` fields used for materialization. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageManifest { + pub name: String, + pub version: String, + #[serde(default)] + pub dependencies: BTreeMap, + /// Declared FHIR versions, e.g. `["4.0.1"]`. + #[serde(default)] + pub fhir_versions: Vec, + #[serde(default)] + pub canonical: Option, +} + +impl PackageManifest { + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|e| PackageError::io(path, e))?; + let manifest: Self = serde_json::from_str(&text).map_err(|e| { + PackageError::Manifest(format!("failed to parse {}: {e}", path.display())) + })?; + if manifest.name.is_empty() || manifest.version.is_empty() { + return Err(PackageError::Manifest(format!( + "{} missing name or version", + path.display() + ))); + } + Ok(manifest) + } + + pub fn id(&self) -> PackageId { + PackageId::new(&self.name, &self.version) + } + + /// Dependency pins as exact [`PackageId`]s (version strings used as-is). + pub fn dependency_ids(&self) -> Vec { + self.dependencies + .iter() + .map(|(name, version)| PackageId::new(name, version)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ref() { + let r = PackageRef::parse("hl7.fhir.r4.core@4.0.1").unwrap(); + assert_eq!(r.name, "hl7.fhir.r4.core"); + assert_eq!(r.version, "4.0.1"); + } +} diff --git a/crates/fhir-validator/src/packages/materialize.rs b/crates/fhir-validator/src/packages/materialize.rs new file mode 100644 index 000000000..41e5772b6 --- /dev/null +++ b/crates/fhir-validator/src/packages/materialize.rs @@ -0,0 +1,91 @@ +use crate::converter; +use crate::packages::error::PackageError; +use crate::packages::scan::scan_package_dir; +use crate::resolver::SchemaRegistry; +use serde_json::Value; +use std::path::Path; + +/// Names of FHIR infrastructure roots excluded from package registries. +const ABSTRACT_ROOTS: &[&str] = &["Element", "BackboneElement", "Resource", "DomainResource"]; + +/// Outcome of converting a package's StructureDefinitions into a registry. +#[derive(Debug, Clone, Default)] +pub struct MaterializeReport { + pub inserted: usize, + pub skipped_abstract: usize, + pub convert_errors: Vec, + pub warnings: Vec, + pub code_systems_seen: usize, + pub value_sets_seen: usize, +} + +/// Scan `package_root`, convert StructureDefinitions, insert into a new +/// [`SchemaRegistry`]. Soft-fails individual bad SDs (recorded in the report); +/// always returns a registry (possibly empty). +pub fn materialize_package( + package_root: &Path, +) -> Result<(SchemaRegistry, MaterializeReport), PackageError> { + let scanned = scan_package_dir(package_root)?; + let mut registry = SchemaRegistry::new(); + let mut report = MaterializeReport { + code_systems_seen: scanned.code_system_paths.len(), + value_sets_seen: scanned.value_set_paths.len(), + warnings: scanned.skipped_files, + ..Default::default() + }; + + for sd in &scanned.structure_definitions { + if should_skip_sd(sd) { + report.skipped_abstract += 1; + continue; + } + match converter::convert(sd) { + Ok(conversion) => { + for w in conversion.warnings { + report.warnings.push(w); + } + if registry.insert(conversion.schema) { + report.inserted += 1; + } else { + report.warnings.push(format!( + "StructureDefinition has neither url nor name: {}", + sd_label(sd) + )); + } + } + Err(e) => { + report.convert_errors.push(format!("{}: {e}", sd_label(sd))); + } + } + } + + Ok((registry, report)) +} + +fn should_skip_sd(sd: &Value) -> bool { + let name = sd.get("name").and_then(Value::as_str).unwrap_or(""); + let id = sd.get("id").and_then(Value::as_str).unwrap_or(""); + if ABSTRACT_ROOTS.contains(&name) || ABSTRACT_ROOTS.contains(&id) { + return true; + } + // Abstract infrastructure without derivation — same class of poison as Element. + let is_abstract = sd.get("abstract").and_then(Value::as_bool) == Some(true); + let has_derivation = sd + .get("derivation") + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()); + let has_base = sd + .get("baseDefinition") + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()); + is_abstract && !has_derivation && !has_base +} + +fn sd_label(sd: &Value) -> String { + sd.get("url") + .and_then(Value::as_str) + .or_else(|| sd.get("name").and_then(Value::as_str)) + .or_else(|| sd.get("id").and_then(Value::as_str)) + .unwrap_or("") + .to_string() +} diff --git a/crates/fhir-validator/src/packages/mod.rs b/crates/fhir-validator/src/packages/mod.rs new file mode 100644 index 000000000..e0b28ed08 --- /dev/null +++ b/crates/fhir-validator/src/packages/mod.rs @@ -0,0 +1,157 @@ +//! FHIR NPM / IG package materialization. +//! +//! #232 already makes package overlays the native validation shape: convert +//! StructureDefinitions to FHIR Schemas and push a [`SchemaRegistry`] layer +//! over the embedded core pack. This module is **materialization proper** — +//! loading full IG/NPM packages from a curated on-disk cache, resolving +//! `package.json` dependencies against that cache only, and producing +//! registry layers for [`CompositeResolver`](crate::CompositeResolver). +//! +//! ## Cache layout +//! +//! ```text +//! {cache}/{package-name}/{version}/ +//! package.json +//! StructureDefinition-….json +//! … +//! .sha256 # optional integrity of the source .tgz +//! ``` +//! +//! Packages are expanded with the FHIR NPM `package/` prefix stripped so +//! `package.json` sits at the version directory root. +//! +//! Populate the cache from **any local source** via +//! [`PackageCache::ensure_from_path`] (`.tgz`, expanded dir, or IG publisher +//! `output/` which selects `package.tgz`). HTTP(S) seeding is handled by the +//! HFS REST layer (`HFS_FHIR_PACKAGE_SOURCES`) so this crate stays +//! filesystem-only at validate time. The cache's `.staging/` directory is +//! only a temporary unpack workspace — not a package source. +//! +//! ## Abstract StructureDefinitions +//! +//! `Element`, `BackboneElement`, `Resource`, and `DomainResource` are skipped +//! during materialization. They are FHIR infrastructure roots, not useful +//! profile targets, and including them can abort converters that require +//! `derivation` / `baseDefinition`. +//! +//! ## Terminology +//! +//! CodeSystem / ValueSet resources found in a package are discovered but +//! **not** loaded into the schema registry — import those via HTS. + +mod cache; +mod error; +mod manifest; +mod materialize; +mod resolve; +mod scan; +mod version; + +pub use cache::PackageCache; +pub use error::PackageError; +pub use manifest::{PackageId, PackageManifest, PackageRef}; +pub use materialize::{MaterializeReport, materialize_package}; +pub use resolve::{ResolvedPackage, resolve_packages}; +pub use scan::{ScannedPackage, scan_package_dir}; +pub use version::manifest_supports_fhir_version; + +/// Install `path` into `cache` then return the package id. +/// +/// See [`PackageCache::ensure_from_path`] for accepted layouts (`.tgz`, +/// expanded package dir, IG publisher `output/`). +pub fn ensure_package_path(cache: &PackageCache, path: &Path) -> Result { + cache.ensure_from_path(path) +} + +use crate::resolver::SchemaRegistry; +use helios_fhir::FhirVersion; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +/// Resolve roots against `cache`, materialize each package into a schema +/// registry, and return layers in **CompositeResolver order** (earlier wins): +/// configured roots first (config order), then transitive dependencies +/// (dependents before deeper deps), so a root IG overrides dependency +/// profiles with the same canonical URL. +/// +/// When `fhir_version` is `Some`, packages whose `fhirVersions` are +/// incompatible with that release are rejected ([`PackageError::Resolve`]). +pub fn materialize_package_layers( + cache: &PackageCache, + roots: &[PackageRef], + fhir_version: Option, +) -> Result, MaterializeReport)>, PackageError> { + let resolved = resolve_packages(cache, roots, fhir_version)?; + // resolve_packages returns deps-first topo order; reverse for overlay + // precedence (dependents / roots win over dependencies). + let mut layers = Vec::with_capacity(resolved.len()); + for pkg in resolved.into_iter().rev() { + let (registry, report) = materialize_package(&pkg.path)?; + layers.push((pkg.id, Arc::new(registry), report)); + } + Ok(layers) +} + +/// Materialize package layers once, then partition them by every version in +/// `versions`. Packages with an empty `fhirVersions` list appear under every +/// requested version; otherwise only matching versions receive the layer. +/// +/// Root packages that do not support `default_version` fail with +/// [`PackageError::Resolve`] so a misconfigured IG cannot boot silently. +pub fn materialize_package_layers_by_version( + cache: &PackageCache, + roots: &[PackageRef], + default_version: FhirVersion, + versions: &[FhirVersion], +) -> Result>>, PackageError> { + // Resolve without a version filter so multi-version servers can keep + // packages that only apply to a subset of enabled releases. + let resolved = resolve_packages(cache, roots, None)?; + + for root in roots { + let Some(pkg) = resolved.iter().find(|p| p.id == *root) else { + continue; + }; + if !manifest_supports_fhir_version(&pkg.manifest.fhir_versions, default_version) { + return Err(PackageError::Resolve(format!( + "package {} declares fhirVersions {:?} incompatible with default FHIR version {}", + pkg.id, + pkg.manifest.fhir_versions, + default_version.full_version() + ))); + } + } + + // Materialize in dependents-first overlay order once. + let mut materialized: Vec<(PackageRef, Arc, PackageManifest)> = + Vec::with_capacity(resolved.len()); + for pkg in resolved.into_iter().rev() { + let (registry, _report) = materialize_package(&pkg.path)?; + materialized.push((pkg.id, Arc::new(registry), pkg.manifest)); + } + + let mut out: HashMap>> = HashMap::new(); + for &version in versions { + let mut layers = Vec::new(); + for (_id, registry, manifest) in &materialized { + if manifest_supports_fhir_version(&manifest.fhir_versions, version) { + layers.push(Arc::clone(registry)); + } + } + out.insert(version, layers); + } + Ok(out) +} + +/// Convenience: ensure a `.tgz` is in the cache, then materialize that single +/// package (no dependency walk). +pub fn materialize_tgz( + cache: &PackageCache, + tgz: &Path, +) -> Result<(PackageRef, SchemaRegistry, MaterializeReport), PackageError> { + let id = cache.ensure_from_tgz(tgz)?; + let path = cache.get(&id)?; + let (registry, report) = materialize_package(&path)?; + Ok((id, registry, report)) +} diff --git a/crates/fhir-validator/src/packages/resolve.rs b/crates/fhir-validator/src/packages/resolve.rs new file mode 100644 index 000000000..f082437c2 --- /dev/null +++ b/crates/fhir-validator/src/packages/resolve.rs @@ -0,0 +1,115 @@ +use crate::packages::cache::PackageCache; +use crate::packages::error::PackageError; +use crate::packages::manifest::{PackageId, PackageManifest, PackageRef}; +use crate::packages::version::manifest_supports_fhir_version; +use helios_fhir::FhirVersion; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::PathBuf; + +/// A package located in the cache, ready to materialize. +#[derive(Debug, Clone)] +pub struct ResolvedPackage { + pub id: PackageId, + pub path: PathBuf, + pub manifest: PackageManifest, +} + +/// Resolve `roots` and their transitive `package.json` dependencies from +/// `cache` only (offline). Returns packages in **deps-first** topological +/// order (dependencies before dependents). Callers that need overlay +/// precedence (dependents win) should reverse this list. +/// +/// When `fhir_version` is `Some`, every resolved package must declare a +/// compatible `fhirVersions` entry (or omit the field). Incompatible packages +/// yield [`PackageError::Resolve`]. +pub fn resolve_packages( + cache: &PackageCache, + roots: &[PackageRef], + fhir_version: Option, +) -> Result, PackageError> { + if roots.is_empty() { + return Ok(Vec::new()); + } + + // Collect closure. + let mut manifests: BTreeMap = BTreeMap::new(); + let mut pending: Vec = roots.to_vec(); + while let Some(id) = pending.pop() { + if manifests.contains_key(&id) { + continue; + } + let path = cache.get(&id)?; + let manifest = PackageManifest::load(&path.join("package.json"))?; + if let Some(version) = fhir_version + && !manifest_supports_fhir_version(&manifest.fhir_versions, version) + { + return Err(PackageError::Resolve(format!( + "package {id} declares fhirVersions {:?} incompatible with {}", + manifest.fhir_versions, + version.full_version() + ))); + } + for dep in manifest.dependency_ids() { + if !manifests.contains_key(&dep) { + pending.push(dep); + } + } + manifests.insert(id, (path, manifest)); + } + + // Edges: dependency -> dependent (dep must come first). + let mut indegree: BTreeMap = + manifests.keys().cloned().map(|id| (id, 0)).collect(); + let mut outgoing: BTreeMap> = BTreeMap::new(); + + for (id, (_, manifest)) in &manifests { + for dep in manifest.dependency_ids() { + if !manifests.contains_key(&dep) { + return Err(PackageError::NotInCache { + name: dep.name, + version: dep.version, + }); + } + *indegree.get_mut(id).expect("id present") += 1; + outgoing.entry(dep).or_default().push(id.clone()); + } + } + + let mut queue: VecDeque = indegree + .iter() + .filter(|(_, d)| **d == 0) + .map(|(id, _)| id.clone()) + .collect(); + + let mut ordered = Vec::with_capacity(manifests.len()); + let mut seen: BTreeSet = BTreeSet::new(); + while let Some(id) = queue.pop_front() { + if !seen.insert(id.clone()) { + continue; + } + ordered.push(id.clone()); + if let Some(dependents) = outgoing.get(&id) { + for dep_of in dependents { + let d = indegree.get_mut(dep_of).expect("present"); + *d = d.saturating_sub(1); + if *d == 0 { + queue.push_back(dep_of.clone()); + } + } + } + } + + if ordered.len() != manifests.len() { + return Err(PackageError::Resolve( + "dependency cycle detected among cached packages".into(), + )); + } + + Ok(ordered + .into_iter() + .map(|id| { + let (path, manifest) = manifests.remove(&id).expect("id in manifests"); + ResolvedPackage { id, path, manifest } + }) + .collect()) +} diff --git a/crates/fhir-validator/src/packages/scan.rs b/crates/fhir-validator/src/packages/scan.rs new file mode 100644 index 000000000..7dad9f4c8 --- /dev/null +++ b/crates/fhir-validator/src/packages/scan.rs @@ -0,0 +1,106 @@ +use crate::packages::error::PackageError; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// JSON resources discovered under an expanded FHIR NPM package. +#[derive(Debug, Clone, Default)] +pub struct ScannedPackage { + pub structure_definitions: Vec, + /// Paths of CodeSystem JSON (not loaded into the schema registry). + pub code_system_paths: Vec, + /// Paths of ValueSet JSON (not loaded into the schema registry). + pub value_set_paths: Vec, + pub skipped_files: Vec, +} + +/// Walk `package_root` for `*.json`, collect StructureDefinition resources +/// (standalone or Bundle entries). Skips `.index.json` and `package.json`. +pub fn scan_package_dir(package_root: &Path) -> Result { + if !package_root.is_dir() { + return Err(PackageError::Invalid(format!( + "package root is not a directory: {}", + package_root.display() + ))); + } + + let mut json_files = Vec::new(); + walk_json_files(package_root, &mut json_files)?; + json_files.sort(); + json_files.dedup(); + + let mut out = ScannedPackage::default(); + for path in json_files { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if name == "package.json" || name == ".index.json" || name == ".sha256" { + continue; + } + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + out.skipped_files + .push(format!("{}: read error: {e}", path.display())); + continue; + } + }; + let v: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + out.skipped_files + .push(format!("{}: JSON parse error: {e}", path.display())); + continue; + } + }; + collect_from_value(&v, &path, &mut out); + } + Ok(out) +} + +fn collect_from_value(v: &Value, path: &Path, out: &mut ScannedPackage) { + match v.get("resourceType").and_then(Value::as_str) { + Some("StructureDefinition") => out.structure_definitions.push(v.clone()), + Some("CodeSystem") => out.code_system_paths.push(path.to_path_buf()), + Some("ValueSet") => out.value_set_paths.push(path.to_path_buf()), + Some("Bundle") => { + if let Some(entries) = v.get("entry").and_then(Value::as_array) { + for entry in entries { + if let Some(res) = entry.get("resource") { + match res.get("resourceType").and_then(Value::as_str) { + Some("StructureDefinition") => { + out.structure_definitions.push(res.clone()); + } + Some("CodeSystem") => { + out.code_system_paths.push(path.to_path_buf()); + } + Some("ValueSet") => { + out.value_set_paths.push(path.to_path_buf()); + } + _ => {} + } + } + } + } + } + _ => {} + } +} + +fn walk_json_files(dir: &Path, out: &mut Vec) -> Result<(), PackageError> { + for ent in fs::read_dir(dir).map_err(|e| PackageError::io(dir, e))? { + let ent = ent.map_err(|e| PackageError::io(dir, e))?; + let p = ent.path(); + if p.is_dir() { + walk_json_files(&p, out)?; + } else if p + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("json")) + { + out.push(p); + } + } + Ok(()) +} diff --git a/crates/fhir-validator/src/packages/version.rs b/crates/fhir-validator/src/packages/version.rs new file mode 100644 index 000000000..da305c27b --- /dev/null +++ b/crates/fhir-validator/src/packages/version.rs @@ -0,0 +1,59 @@ +//! FHIR version matching for NPM `package.json` `fhirVersions`. + +use helios_fhir::FhirVersion; + +/// Whether a package that declares `fhir_versions` is compatible with `version`. +/// +/// An empty declaration is treated as compatible with every release (many +/// fixtures and older packages omit the field). Declared strings may be full +/// versions (`4.0.1`), MIME params (`4.0`), or short labels (`R4` / `r4`). +pub fn manifest_supports_fhir_version(fhir_versions: &[String], version: FhirVersion) -> bool { + if fhir_versions.is_empty() { + return true; + } + fhir_versions + .iter() + .any(|declared| declared_matches_version(declared, version)) +} + +fn declared_matches_version(declared: &str, version: FhirVersion) -> bool { + let d = declared.trim(); + if d.eq_ignore_ascii_case(version.as_str()) { + return true; + } + if d == version.as_mime_param() || d == version.full_version() { + return true; + } + // Prefix match: "4.0.1" accepts MIME "4.0"; "5.0.0-snapshot1" accepts "5.0". + let mime = version.as_mime_param(); + d.starts_with(mime) + && d.as_bytes() + .get(mime.len()) + .is_none_or(|b| *b == b'.' || *b == b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "R4")] + fn empty_supports_all() { + assert!(manifest_supports_fhir_version(&[], FhirVersion::R4)); + } + + #[test] + #[cfg(feature = "R4")] + fn r4_aliases() { + for s in ["4.0.1", "4.0", "R4", "r4"] { + assert!( + manifest_supports_fhir_version(&[s.into()], FhirVersion::R4), + "{s}" + ); + } + assert!(!manifest_supports_fhir_version( + &["5.0.0".into()], + FhirVersion::R4 + )); + } +} diff --git a/crates/fhir-validator/src/questionnaire.rs b/crates/fhir-validator/src/questionnaire.rs new file mode 100644 index 000000000..ab875da15 --- /dev/null +++ b/crates/fhir-validator/src/questionnaire.rs @@ -0,0 +1,620 @@ +//! QuestionnaireResponse validation against a Questionnaire definition. +//! +//! Structural profile validation still applies via the main engine; this +//! module adds Questionnaire-driven checks: known `linkId`s, required items +//! (when enabled), answer type vs `item.type`, `answerOption` membership, and +//! `answerValueSet` membership via an optional [`TerminologyProvider`]. + +use crate::effects::{CodedValue, TerminologyProvider}; +use crate::engine::{ErrorKind, Severity, ValidationError}; +use serde_json::Value; +use std::collections::HashMap; + +/// Validate `qr` against `questionnaire`. +/// +/// `terminology` is used only for `answerValueSet` checks; when absent those +/// checks are skipped (not failed). +pub async fn validate_questionnaire_response( + qr: &Value, + questionnaire: &Value, + terminology: Option<&dyn TerminologyProvider>, +) -> Vec { + let mut errors = Vec::new(); + let defs = index_questionnaire_items(questionnaire); + let answers = collect_qr_items(qr); + + // Required items that are enabled must appear. + for (link_id, def) in &defs { + if !def.required { + continue; + } + if !item_enabled(def, &answers, &defs) { + continue; + } + if !answers.contains_key(link_id.as_str()) { + errors.push(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}]"), + format!("required Questionnaire item '{link_id}' is missing"), + )); + } + } + + for (link_id, answered) in &answers { + let Some(def) = defs.get(link_id.as_str()) else { + errors.push(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}]"), + format!("linkId '{link_id}' is not defined by the Questionnaire"), + )); + continue; + }; + if !item_enabled(def, &answers, &defs) && !answered.answers.is_empty() { + errors.push( + ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}]"), + format!("item '{link_id}' has answers but enableWhen is not satisfied"), + ) + .with_severity(Severity::Warning), + ); + } + for answer in &answered.answers { + if let Some(err) = check_answer_type(link_id, def, answer) { + errors.push(err); + } + if let Some(err) = check_answer_option(link_id, def, answer) { + errors.push(err); + } + if let Some(vs) = &def.answer_value_set + && let Some(provider) = terminology + && let Some(coded) = answer_as_coded(answer) + && let Ok(false) = provider.validate_code(vs, &coded).await + { + errors.push(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}].answer"), + format!("answer for '{link_id}' is not in answerValueSet '{vs}'"), + )); + } + } + } + + errors +} + +#[derive(Debug, Clone)] +struct ItemDef { + type_: String, + required: bool, + answer_options: Vec, + answer_value_set: Option, + enable_when: Vec, + enable_behavior: String, +} + +#[derive(Debug, Clone)] +struct EnableWhen { + question: String, + operator: String, + answer: Option, +} + +#[derive(Debug, Default)] +struct AnsweredItem { + answers: Vec, +} + +fn index_questionnaire_items(questionnaire: &Value) -> HashMap { + let mut out = HashMap::new(); + if let Some(items) = questionnaire.get("item").and_then(Value::as_array) { + walk_q_items(items, &mut out); + } + out +} + +fn walk_q_items(items: &[Value], out: &mut HashMap) { + for item in items { + if let Some(link_id) = item.get("linkId").and_then(Value::as_str) { + let enable_when = item + .get("enableWhen") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|ew| { + Some(EnableWhen { + question: ew.get("question")?.as_str()?.to_string(), + operator: ew + .get("operator") + .and_then(Value::as_str) + .unwrap_or("exists") + .to_string(), + answer: ew.as_object().and_then(|o| { + o.iter() + .find(|(k, _)| k.starts_with("answer")) + .map(|(_, v)| v.clone()) + }), + }) + }) + .collect() + }) + .unwrap_or_default(); + let answer_options = item + .get("answerOption") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|opt| { + opt.as_object().and_then(|o| { + o.iter() + .find(|(k, _)| k.starts_with("value")) + .map(|(_, v)| v.clone()) + }) + }) + .collect(); + out.insert( + link_id.to_string(), + ItemDef { + type_: item + .get("type") + .and_then(Value::as_str) + .unwrap_or("string") + .to_string(), + required: item + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false), + answer_options, + answer_value_set: item + .get("answerValueSet") + .and_then(Value::as_str) + .map(str::to_string), + enable_when, + enable_behavior: item + .get("enableBehavior") + .and_then(Value::as_str) + .unwrap_or("all") + .to_string(), + }, + ); + } + if let Some(nested) = item.get("item").and_then(Value::as_array) { + walk_q_items(nested, out); + } + } +} + +fn collect_qr_items(qr: &Value) -> HashMap { + let mut out = HashMap::new(); + if let Some(items) = qr.get("item").and_then(Value::as_array) { + walk_qr_items(items, &mut out); + } + out +} + +fn walk_qr_items(items: &[Value], out: &mut HashMap) { + for item in items { + if let Some(link_id) = item.get("linkId").and_then(Value::as_str) { + let answers = item + .get("answer") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + out.entry(link_id.to_string()) + .or_default() + .answers + .extend(answers); + } + if let Some(nested) = item.get("item").and_then(Value::as_array) { + walk_qr_items(nested, out); + } + } +} + +fn item_enabled( + def: &ItemDef, + answers: &HashMap, + _defs: &HashMap, +) -> bool { + if def.enable_when.is_empty() { + return true; + } + let results: Vec = def + .enable_when + .iter() + .map(|ew| eval_enable_when(ew, answers)) + .collect(); + if def.enable_behavior == "any" { + results.iter().any(|r| *r) + } else { + results.iter().all(|r| *r) + } +} + +fn eval_enable_when(ew: &EnableWhen, answers: &HashMap) -> bool { + let answered = answers.get(&ew.question); + match ew.operator.as_str() { + "exists" => { + let exists = answered.is_some_and(|a| !a.answers.is_empty()); + match &ew.answer { + Some(Value::Bool(expected)) => exists == *expected, + None => exists, + _ => exists, + } + } + "=" | "equal" => answered.is_some_and(|a| { + a.answers.iter().any(|ans| { + ew.answer + .as_ref() + .is_some_and(|expected| answer_equals(ans, expected)) + }) + }), + "!=" => answered.is_some_and(|a| { + a.answers.iter().any(|ans| { + ew.answer + .as_ref() + .is_some_and(|expected| !answer_equals(ans, expected)) + }) + }), + _ => true, // unsupported operators: do not block + } +} + +fn answer_equals(answer: &Value, expected: &Value) -> bool { + // Compare the value[x] payload of an answer object to enableWhen.answer[x]. + let actual = answer_value(answer).unwrap_or(answer); + actual == expected +} + +fn answer_value(answer: &Value) -> Option<&Value> { + answer.as_object().and_then(|o| { + o.iter() + .find(|(k, _)| k.starts_with("value")) + .map(|(_, v)| v) + }) +} + +fn check_answer_type(link_id: &str, def: &ItemDef, answer: &Value) -> Option { + let obj = answer.as_object()?; + let key = obj.keys().find(|k| k.starts_with("value"))?; + let expected = match def.type_.as_str() { + "boolean" => "valueBoolean", + "decimal" => "valueDecimal", + "integer" => "valueInteger", + "date" => "valueDate", + "dateTime" => "valueDateTime", + "time" => "valueTime", + "string" | "text" => "valueString", + "url" => "valueUri", + "coding" => "valueCoding", + "quantity" => "valueQuantity", + "reference" => "valueReference", + "attachment" => "valueAttachment", + // display / group / question have no answers + "display" | "group" | "question" => { + return Some(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}].answer"), + format!( + "item '{link_id}' has type '{}' and should not have answers", + def.type_ + ), + )); + } + _ => return None, + }; + if key != expected { + return Some(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}].answer"), + format!( + "answer for '{link_id}' uses '{key}' but Questionnaire type '{}' expects '{expected}'", + def.type_ + ), + )); + } + None +} + +fn check_answer_option(link_id: &str, def: &ItemDef, answer: &Value) -> Option { + if def.answer_options.is_empty() { + return None; + } + let value = answer_value(answer)?; + let ok = def.answer_options.iter().any(|opt| opt == value); + if ok { + None + } else { + Some(ValidationError::new( + ErrorKind::Questionnaire, + format!("QuestionnaireResponse.item[{link_id}].answer"), + format!("answer for '{link_id}' is not one of the Questionnaire answerOption values"), + )) + } +} + +fn answer_as_coded(answer: &Value) -> Option { + let value = answer_value(answer)?; + match value { + Value::String(s) => Some(CodedValue::Code(s.clone())), + Value::Object(o) if o.contains_key("coding") => { + Some(CodedValue::CodeableConcept(value.clone())) + } + Value::Object(_) => Some(CodedValue::Coding(value.clone())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effects::TerminologyError; + use serde_json::json; + + /// Accepts exactly one code, whatever the ValueSet. + struct StubTerminology { + allowed: &'static str, + } + + #[async_trait::async_trait] + impl TerminologyProvider for StubTerminology { + async fn validate_code( + &self, + _value_set: &str, + coded: &CodedValue, + ) -> Result { + let code = match coded { + CodedValue::Code(c) => Some(c.as_str()), + CodedValue::Coding(v) => v.get("code").and_then(Value::as_str), + CodedValue::CodeableConcept(v) => v + .get("coding") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("code")) + .and_then(Value::as_str), + }; + Ok(code == Some(self.allowed)) + } + } + + #[tokio::test] + async fn missing_required_item() { + let q = json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [{ + "linkId": "name", + "type": "string", + "required": true + }] + }); + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "questionnaire": "http://example.org/q", + "item": [] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!( + errors.iter().any(|e| e.message.contains("required")), + "{errors:?}" + ); + } + + #[tokio::test] + async fn answer_type_mismatch() { + let q = json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [{ "linkId": "age", "type": "integer" }] + }); + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ + "linkId": "age", + "answer": [{ "valueString": "12" }] + }] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!( + errors.iter().any(|e| e.message.contains("valueString")), + "{errors:?}" + ); + } + + #[tokio::test] + async fn unknown_link_id() { + let q = json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [{ "linkId": "a", "type": "string" }] + }); + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ "linkId": "nope", "answer": [{ "valueString": "x" }] }] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!( + errors.iter().any(|e| e.message.contains("not defined")), + "{errors:?}" + ); + } + + fn smoker_questionnaire(enable_behavior: Option<&str>) -> Value { + let mut packs = json!({ + "linkId": "packs", + "type": "integer", + "required": true, + "enableWhen": [ + { "question": "smoker", "operator": "=", "answerBoolean": true } + ] + }); + if let Some(behavior) = enable_behavior { + packs["enableBehavior"] = json!(behavior); + packs["enableWhen"] = json!([ + { "question": "smoker", "operator": "=", "answerBoolean": true }, + { "question": "vaper", "operator": "=", "answerBoolean": true } + ]); + } + json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { "linkId": "smoker", "type": "boolean" }, + { "linkId": "vaper", "type": "boolean" }, + packs + ] + }) + } + + #[tokio::test] + async fn enable_when_gates_required_item() { + let q = smoker_questionnaire(None); + + // Condition unmet: the disabled required item may be absent. + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ "linkId": "smoker", "answer": [{ "valueBoolean": false }] }] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!(errors.is_empty(), "{errors:?}"); + + // Condition met: the item becomes required. + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ "linkId": "smoker", "answer": [{ "valueBoolean": true }] }] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!( + errors + .iter() + .any(|e| e.message.contains("required Questionnaire item 'packs'")), + "{errors:?}" + ); + } + + #[tokio::test] + async fn answers_on_disabled_item_warn() { + let q = smoker_questionnaire(None); + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { "linkId": "smoker", "answer": [{ "valueBoolean": false }] }, + { "linkId": "packs", "answer": [{ "valueInteger": 2 }] } + ] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + let warning = errors + .iter() + .find(|e| e.message.contains("enableWhen is not satisfied")) + .unwrap_or_else(|| panic!("expected disabled-item warning, got {errors:?}")); + assert_eq!(warning.severity, Severity::Warning); + } + + #[tokio::test] + async fn enable_behavior_any_enables_on_one_condition() { + let q = smoker_questionnaire(Some("any")); + // Only the second condition (vaper) holds; behavior `any` still + // enables the item, so its absence is a required-item error. + let qr = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { "linkId": "smoker", "answer": [{ "valueBoolean": false }] }, + { "linkId": "vaper", "answer": [{ "valueBoolean": true }] } + ] + }); + let errors = validate_questionnaire_response(&qr, &q, None).await; + assert!( + errors + .iter() + .any(|e| e.message.contains("required Questionnaire item 'packs'")), + "{errors:?}" + ); + } + + #[tokio::test] + async fn answer_option_membership() { + let q = json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [{ + "linkId": "color", + "type": "string", + "answerOption": [ + { "valueString": "red" }, + { "valueString": "blue" } + ] + }] + }); + let qr_ok = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ "linkId": "color", "answer": [{ "valueString": "red" }] }] + }); + let errors = validate_questionnaire_response(&qr_ok, &q, None).await; + assert!(errors.is_empty(), "{errors:?}"); + + let qr_bad = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ "linkId": "color", "answer": [{ "valueString": "green" }] }] + }); + let errors = validate_questionnaire_response(&qr_bad, &q, None).await; + assert!( + errors.iter().any(|e| e.message.contains("answerOption")), + "{errors:?}" + ); + } + + #[tokio::test] + async fn answer_value_set_membership() { + let q = json!({ + "resourceType": "Questionnaire", + "status": "active", + "item": [{ + "linkId": "dx", + "type": "coding", + "answerValueSet": "http://example.org/ValueSet/dx-codes" + }] + }); + let provider = StubTerminology { allowed: "ok" }; + + let qr_ok = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ + "linkId": "dx", + "answer": [{ "valueCoding": { "system": "http://example.org/cs", "code": "ok" } }] + }] + }); + let errors = validate_questionnaire_response(&qr_ok, &q, Some(&provider)).await; + assert!(errors.is_empty(), "{errors:?}"); + + let qr_bad = json!({ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [{ + "linkId": "dx", + "answer": [{ "valueCoding": { "system": "http://example.org/cs", "code": "bad" } }] + }] + }); + let errors = validate_questionnaire_response(&qr_bad, &q, Some(&provider)).await; + assert!( + errors + .iter() + .any(|e| e.message.contains("not in answerValueSet")), + "{errors:?}" + ); + + // Without a provider the check is skipped, not failed. + let errors = validate_questionnaire_response(&qr_bad, &q, None).await; + assert!(errors.is_empty(), "{errors:?}"); + } +} diff --git a/crates/fhir-validator/src/schema.rs b/crates/fhir-validator/src/schema.rs index 23e5f731a..5e8f469a4 100644 --- a/crates/fhir-validator/src/schema.rs +++ b/crates/fhir-validator/src/schema.rs @@ -117,7 +117,17 @@ pub struct FhirSchema { /// equal; extra data keys permitted). #[serde(skip_serializing_if = "Option::is_none")] pub pattern: Option, - /// Terminology binding. Only `required`-strength bindings are enforced. + /// Maximum string length (`ElementDefinition.maxLength`). + #[serde(skip_serializing_if = "Option::is_none", rename = "maxLength")] + pub max_length: Option, + /// Inclusive minimum value (`ElementDefinition.minValue[x]`). + #[serde(skip_serializing_if = "Option::is_none", rename = "minValue")] + pub min_value: Option, + /// Inclusive maximum value (`ElementDefinition.maxValue[x]`). + #[serde(skip_serializing_if = "Option::is_none", rename = "maxValue")] + pub max_value: Option, + /// Terminology binding. Only `required`-strength bindings are enforced + /// by default; `extensible` may emit warnings when opted in. #[serde(skip_serializing_if = "Option::is_none")] pub binding: Option, /// FHIRPath invariants, keyed by constraint id. @@ -252,11 +262,13 @@ pub struct Slice { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Match { - /// `pattern` | `binding` | `profile` | `type` (the reference validator - /// only implements `pattern`; we start there too). + /// `pattern` | `binding` | `profile` | `type` | `exists` | `extension` + /// (the reference validator only implements `pattern`; `exists` carries + /// `[{path, exists}]` entries and `extension` a `{url, pattern | + /// extension}` chain, both Helios extensions of the IR). #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub type_: Option, - /// The pattern / binding / profile / type payload. + /// The pattern / binding / profile / type / exists / extension payload. #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, /// Match against the resolved reference target instead of the reference diff --git a/crates/fhir-validator/tests/common/mod.rs b/crates/fhir-validator/tests/common/mod.rs index 3ec795d46..60279e817 100644 --- a/crates/fhir-validator/tests/common/mod.rs +++ b/crates/fhir-validator/tests/common/mod.rs @@ -73,6 +73,7 @@ pub fn run_fixture_file(sub: &str, name: &str) { // in fixtures are fixture bugs — surface them as errors. use_meta_profiles: true, unknown_profile: UnknownProfilePolicy::Error, + ..Default::default() }; let outcome = validator.validate_sync(&case.data, &opts); let actual = serde_json::to_value(&outcome.errors).expect("errors serialize"); diff --git a/crates/fhir-validator/tests/converter_tests.rs b/crates/fhir-validator/tests/converter_tests.rs index 0088ee57b..2fcea427f 100644 --- a/crates/fhir-validator/tests/converter_tests.rs +++ b/crates/fhir-validator/tests/converter_tests.rs @@ -5,10 +5,11 @@ //! with exact deep equality on the serialized form, so every mapping rule is //! pinned: shape (base.max → array), min → parent required (choice base name //! for `foo[x]`), max 0 → parent excluded, choice expansion, discriminator → -//! pattern match, extension slicing → extensions sugar, contentReference → -//! elementReference, targetProfile → refers, binding/constraint carrying -//! (ele-1/ext-1 dropped on non-root elements), and primitive regex -//! extraction. +//! pattern / type / profile / binding / exists / extension match, extension +//! slicing → extensions sugar, contentReference → elementReference, +//! targetProfile → refers, +//! binding/constraint carrying (ele-1/ext-1 dropped on non-root elements), +//! and primitive regex extraction. use helios_fhir_validator::converter::convert; use serde_json::{Value, json}; @@ -190,6 +191,204 @@ fn converts_primitive_with_regex() { assert_eq!(actual, expected); } +#[test] +fn converts_type_and_profile_slice_discriminators() { + let actual = convert_to_value("slice-discriminators.json"); + let expected = json!({ + "url": "http://example.org/StructureDefinition/slice-discriminators", + "name": "SliceDiscriminators", + "base": "http://hl7.org/fhir/StructureDefinition/Bundle", + "kind": "resource", + "derivation": "constraint", + "type": "Bundle", + "elements": { + "entry": { + "slicing": { + "slices": { + "patient": { + "match": { "type": "type", "value": "Patient" }, + "min": 1, + "max": 1, + "schema": { + "elements": { + "resource": { "type": "Patient" } + }, + "required": ["resource"] + } + }, + "observation": { + "match": { "type": "type", "value": "Observation" }, + "min": 0, + "max": 1, + "schema": { + "elements": { + "resource": { "type": "Observation" } + } + } + } + }, + "rules": "closed", + "ordered": false + } + }, + "identifier": { + "slicing": { + "slices": { + "org": { + "match": { + "type": "profile", + "value": "http://example.org/StructureDefinition/org-ref" + }, + "min": 0, + "max": 1, + "schema": { + "elements": { + "assigner": { + "type": "Reference", + "refers": [ + "http://example.org/StructureDefinition/org-ref" + ] + } + } + } + } + }, + "rules": "open" + } + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn converts_exists_and_extension_path_discriminators() { + let actual = convert_to_value("exists-extension-discriminators.json"); + let expected = json!({ + "url": "http://example.org/StructureDefinition/exists-extension-discriminators", + "name": "ExistsExtensionDiscriminators", + "base": "http://hl7.org/fhir/StructureDefinition/Observation", + "kind": "resource", + "derivation": "constraint", + "type": "Observation", + "elements": { + "component": { + "slicing": { + "slices": { + "withValue": { + "match": { + "type": "exists", + "value": [{ "path": ["value"], "exists": true }] + }, + "min": 1, + "schema": { + "required": ["value"], + "elements": { + "value": { "choices": ["valueQuantity"] }, + "valueQuantity": { "type": "Quantity", "choiceOf": "value" } + } + } + }, + "noValue": { + "match": { + "type": "exists", + "value": [{ "path": ["value"], "exists": false }] + }, + "min": 0, + "schema": { "excluded": ["value"] } + } + }, + "rules": "open" + } + }, + "identifier": { + "slicing": { + "slices": { + "kindA": { + "match": { + "type": "extension", + "value": { + "url": "http://example.org/ext-kind", + "pattern": { "valueString": "A" } + } + }, + "min": 0, + "max": 1, + "schema": { + "elements": { + "extension": { + "slicing": { + "slices": { + "kind": { + "match": { + "type": "pattern", + "value": { "url": "http://example.org/ext-kind" } + }, + "min": 1, + "max": 1, + "schema": { + "elements": { + "url": { "fixed": "http://example.org/ext-kind" }, + "value": { "choices": ["valueString"] }, + "valueString": { + "type": "string", + "choiceOf": "value", + "fixed": "A" + } + } + } + } + }, + "rules": "open" + } + } + } + } + } + }, + "rules": "open" + } + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn converts_binding_slice_discriminator() { + let actual = convert_to_value("binding-slice.json"); + let expected = json!({ + "url": "http://example.org/StructureDefinition/binding-slice", + "name": "BindingSlice", + "base": "http://hl7.org/fhir/StructureDefinition/Observation", + "kind": "resource", + "derivation": "constraint", + "type": "Observation", + "elements": { + "category": { + "array": true, + "slicing": { + "slices": { + "laboratory": { + "match": { "type": "binding", "value": "laboratory" }, + "min": 1, + "max": 1, + "schema": { + "binding": { + "valueSet": "laboratory", + "strength": "required" + } + } + } + }, + "rules": "open" + } + } + } + }); + assert_eq!(actual, expected); +} + #[test] fn carries_informational_mirrors_and_short_labels() { let sd = json!({ diff --git a/crates/fhir-validator/tests/extended.rs b/crates/fhir-validator/tests/extended.rs index 390091a54..2ced1319b 100644 --- a/crates/fhir-validator/tests/extended.rs +++ b/crates/fhir-validator/tests/extended.rs @@ -4,7 +4,8 @@ //! These fixtures pin behavior the upstream conformance suite leaves //! unspecified: `excluded`, numeric array cardinality messages, fixed/pattern //! messages, slicing rules (closed/openAtEnd/ordered/@default, prohibited -//! `max: 0` slices), primitive-extension sidecars (`_field`), +//! `max: 0` slices), slice matchers (`type` / `profile` / `binding` and +//! extension url/profile/sugar), primitive-extension sidecars (`_field`), //! `elementReference` recursion, and required-satisfied-by-choice-branch. //! Same fixture format and exact-match contract as the upstream suite. @@ -39,6 +40,26 @@ fn extended_slicing_rules() { run_extended("slicing_rules.json"); } +#[test] +fn extended_slice_matchers() { + run_extended("slice_matchers.json"); +} + +#[test] +fn extended_exists_extension_matchers() { + run_extended("exists_extension_matchers.json"); +} + +#[test] +fn extended_value_keywords() { + run_extended("value_keywords.json"); +} + +#[test] +fn extended_reslicing() { + run_extended("reslicing.json"); +} + #[test] fn extended_sidecars() { run_extended("sidecars.json"); diff --git a/crates/fhir-validator/tests/extensible_bindings.rs b/crates/fhir-validator/tests/extensible_bindings.rs new file mode 100644 index 000000000..bf416eb3a --- /dev/null +++ b/crates/fhir-validator/tests/extensible_bindings.rs @@ -0,0 +1,100 @@ +//! Opt-in extensible-strength binding warnings. + +use async_trait::async_trait; +use helios_fhir::FhirVersion; +use helios_fhir_validator::{ + CodedValue, EffectHandlers, FhirSchema, SchemaRegistry, Severity, TerminologyError, + TerminologyProvider, ValidationOptions, Validator, +}; +use serde_json::json; +use std::sync::Arc; + +struct RejectAll; + +#[async_trait] +impl TerminologyProvider for RejectAll { + async fn validate_code( + &self, + _value_set: &str, + _coded: &CodedValue, + ) -> Result { + Ok(false) + } +} + +fn validator() -> Validator { + let mut registry = SchemaRegistry::new(); + registry.insert_named( + "string", + serde_json::from_value::(json!({ "kind": "primitive-type" })).unwrap(), + ); + registry.insert_named( + "CodeableConcept", + serde_json::from_value::(json!({ + "elements": { "text": { "type": "string" } } + })) + .unwrap(), + ); + registry.insert_named( + "Patient", + serde_json::from_value::(json!({ + "elements": { + "resourceType": { "type": "string" }, + "maritalStatus": { + "type": "CodeableConcept", + "binding": { + "valueSet": "http://hl7.org/fhir/ValueSet/marital-status", + "strength": "extensible" + } + } + } + })) + .unwrap(), + ); + Validator::new(Arc::new(registry)) +} + +#[tokio::test] +async fn extensible_unchecked_by_default() { + let reject = RejectAll; + let handlers = EffectHandlers { + terminology: Some(&reject), + ..Default::default() + }; + let errors = validator() + .validate( + &json!({ + "resourceType": "Patient", + "maritalStatus": { "text": "married" } + }), + FhirVersion::R4, + &ValidationOptions::default(), + &handlers, + ) + .await; + assert!(errors.is_empty(), "{errors:?}"); +} + +#[tokio::test] +async fn extensible_warns_when_opted_in() { + let reject = RejectAll; + let handlers = EffectHandlers { + terminology: Some(&reject), + check_extensible_bindings: true, + ..Default::default() + }; + let errors = validator() + .validate( + &json!({ + "resourceType": "Patient", + "maritalStatus": { "text": "married" } + }), + FhirVersion::R4, + &ValidationOptions::default(), + &handlers, + ) + .await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert_eq!(errors[0].severity, Severity::Warning); + assert!(errors[0].message.contains("marital-status")); +} diff --git a/crates/fhir-validator/tests/fixtures/extended/exists_extension_matchers.json b/crates/fhir-validator/tests/fixtures/extended/exists_extension_matchers.json new file mode 100644 index 000000000..45a05e32b --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/exists_extension_matchers.json @@ -0,0 +1,301 @@ +{ + "desc": "exists and extension slice matchers — Helios extended (exists presence/absence with choice-key fallback and multi-entry matchers; extension url+pattern matching including nested complex-extension chains)", + "schemas": { + "string": { "kind": "primitive-type" }, + + "ResourceWithComponents": { + "elements": { + "resourceType": { "type": "string" }, + "component": { + "array": true, + "elements": { + "code": {}, + "valueQuantity": { "elements": { "value": {} } }, + "dataAbsentReason": {} + } + } + } + }, + "ExistsSlice": { + "base": "ResourceWithComponents", + "elements": { + "component": { + "slicing": { + "slices": { + "withValue": { + "match": { + "type": "exists", + "value": [{ "path": ["value"], "exists": true }] + }, + "min": 1 + }, + "noValue": { + "match": { + "type": "exists", + "value": [{ "path": ["value"], "exists": false }] + }, + "max": 1 + } + } + } + } + } + }, + "ExistsMultiSlice": { + "base": "ResourceWithComponents", + "elements": { + "component": { + "slicing": { + "slices": { + "codedNoValue": { + "match": { + "type": "exists", + "value": [ + { "path": ["code"], "exists": true }, + { "path": ["value"], "exists": false } + ] + }, + "min": 1 + } + } + } + } + } + }, + + "ResourceWithIdents": { + "elements": { + "resourceType": { "type": "string" }, + "identifier": { + "array": true, + "elements": { + "system": {}, + "extension": { + "array": true, + "elements": { + "url": {}, + "valueString": {}, + "valueCode": {}, + "extension": { + "array": true, + "elements": { + "url": {}, + "valueCode": {} + } + } + } + } + } + } + } + }, + "ExtensionValueSlice": { + "base": "ResourceWithIdents", + "elements": { + "identifier": { + "slicing": { + "slices": { + "kindA": { + "match": { + "type": "extension", + "value": { + "url": "http://example.org/ext-kind", + "pattern": { "valueString": "A" } + } + }, + "min": 1, + "max": 1 + } + } + } + } + } + }, + "NestedExtensionSlice": { + "base": "ResourceWithIdents", + "elements": { + "identifier": { + "slicing": { + "slices": { + "nested": { + "match": { + "type": "extension", + "value": { + "url": "http://example.org/outer", + "extension": { + "url": "http://example.org/inner", + "pattern": { "valueCode": "x" } + } + } + }, + "min": 1 + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "exists: choice-key value satisfies exists: true; value-less item stays in noValue", + "schemas": ["ExistsSlice"], + "data": { + "resourceType": "ResourceWithComponents", + "component": [ + { "code": "a", "valueQuantity": { "value": 1 } }, + { "code": "b", "dataAbsentReason": "unknown" } + ] + } + }, + { + "desc": "exists: no item with value violates withValue min", + "schemas": ["ExistsSlice"], + "data": { + "resourceType": "ResourceWithComponents", + "component": [{ "code": "b" }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithComponents.component", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "exists: two value-less items exceed noValue max", + "schemas": ["ExistsSlice"], + "data": { + "resourceType": "ResourceWithComponents", + "component": [ + { "code": "a", "valueQuantity": { "value": 1 } }, + { "code": "b" }, + { "code": "c" } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithComponents.component", + "message": "Slice defines the following max cardinality: '1', actual cardinality: '2'" + } + ] + }, + { + "desc": "exists: multi-entry matcher requires code present and value absent", + "schemas": ["ExistsMultiSlice"], + "data": { + "resourceType": "ResourceWithComponents", + "component": [{ "code": "a" }] + } + }, + { + "desc": "exists: multi-entry matcher unmet when value present on every coded item", + "schemas": ["ExistsMultiSlice"], + "data": { + "resourceType": "ResourceWithComponents", + "component": [{ "code": "a", "valueQuantity": { "value": 1 } }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithComponents.component", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + + { + "desc": "extension: url + valueString pattern matches", + "schemas": ["ExtensionValueSlice"], + "data": { + "resourceType": "ResourceWithIdents", + "identifier": [ + { + "system": "http://example.org/mrn", + "extension": [ + { "url": "http://example.org/other", "valueString": "A" }, + { "url": "http://example.org/ext-kind", "valueString": "A" } + ] + } + ] + } + }, + { + "desc": "extension: right url but wrong value does not satisfy required slice", + "schemas": ["ExtensionValueSlice"], + "data": { + "resourceType": "ResourceWithIdents", + "identifier": [ + { + "extension": [{ "url": "http://example.org/ext-kind", "valueString": "B" }] + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithIdents.identifier", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "extension: item without extensions does not satisfy required slice", + "schemas": ["ExtensionValueSlice"], + "data": { + "resourceType": "ResourceWithIdents", + "identifier": [{ "system": "http://example.org/mrn" }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithIdents.identifier", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "extension: nested complex-extension chain matches", + "schemas": ["NestedExtensionSlice"], + "data": { + "resourceType": "ResourceWithIdents", + "identifier": [ + { + "extension": [ + { + "url": "http://example.org/outer", + "extension": [{ "url": "http://example.org/inner", "valueCode": "x" }] + } + ] + } + ] + } + }, + { + "desc": "extension: nested chain with wrong inner code does not match", + "schemas": ["NestedExtensionSlice"], + "data": { + "resourceType": "ResourceWithIdents", + "identifier": [ + { + "extension": [ + { + "url": "http://example.org/outer", + "extension": [{ "url": "http://example.org/inner", "valueCode": "y" }] + } + ] + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithIdents.identifier", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/refers.json b/crates/fhir-validator/tests/fixtures/extended/refers.json new file mode 100644 index 000000000..13a90fda2 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/refers.json @@ -0,0 +1,39 @@ +{ + "desc": "refers target-type enforcement — Helios extended (requires enforce_refers)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Reference": { + "elements": { + "reference": { "type": "string" }, + "display": { "type": "string" } + } + }, + "Resource": { + "elements": { + "resourceType": { "type": "string" }, + "subject": { + "type": "Reference", + "refers": ["Patient", "Group"] + } + } + } + }, + "tests": [ + { + "desc": "allowed Patient reference", + "data": { + "resourceType": "Resource", + "subject": { "reference": "Patient/123" } + } + }, + { + "desc": "disallowed Organization reference", + "skip": true, + "comment": "refers enforcement is opt-in via ValidationOptions.enforce_refers; covered by unit test refers_enforcement.rs", + "data": { + "resourceType": "Resource", + "subject": { "reference": "Organization/1" } + } + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/reslicing.json b/crates/fhir-validator/tests/fixtures/extended/reslicing.json new file mode 100644 index 000000000..87b125e2d --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/reslicing.json @@ -0,0 +1,71 @@ +{ + "desc": "reslicing — Helios extended (parent/child slice names)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": { "type": "string" }, + "identifier": { + "array": true, + "elements": { + "system": { "type": "string" }, + "value": { "type": "string" }, + "use": { "type": "string" } + } + } + } + }, + "ResliceProfile": { + "base": "Resource", + "elements": { + "identifier": { + "slicing": { + "rules": "open", + "slices": { + "mrn": { + "match": { "type": "pattern", "value": { "system": "http://example.org/mrn" } }, + "min": 1, + "max": 2 + }, + "mrn/primary": { + "reslice": "mrn", + "match": { "type": "pattern", "value": { "use": "official" } }, + "min": 1, + "max": 1 + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "reslice primary present", + "schemas": ["ResliceProfile"], + "data": { + "resourceType": "Resource", + "identifier": [ + { "system": "http://example.org/mrn", "value": "1", "use": "official" } + ] + } + }, + { + "desc": "reslice primary missing", + "schemas": ["ResliceProfile"], + "data": { + "resourceType": "Resource", + "identifier": [ + { "system": "http://example.org/mrn", "value": "1", "use": "usual" } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "Resource.identifier", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/slice_matchers.json b/crates/fhir-validator/tests/fixtures/extended/slice_matchers.json new file mode 100644 index 000000000..3a8270e45 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/slice_matchers.json @@ -0,0 +1,727 @@ +{ + "desc": "slice matchers — Helios extended (type / profile / binding discriminators, extension url/profile matching, closed extension slices; pattern matchers are covered by upstream 5_slices + slicing_rules)", + "schemas": { + "string": { "kind": "primitive-type" }, + "code": { "kind": "primitive-type" }, + "uri": { "kind": "primitive-type" }, + "Extension": { + "elements": { + "url": { "type": "uri" }, + "value": { "choices": ["valueString", "valueCode", "valueCoding"] }, + "valueString": { "choiceOf": "value", "type": "string" }, + "valueCode": { "choiceOf": "value", "type": "code" }, + "valueCoding": { "choiceOf": "value", "type": "Coding" } + } + }, + "Coding": { + "elements": { + "system": { "type": "uri" }, + "code": { "type": "code" }, + "display": { "type": "string" } + } + }, + "Patient": { + "type": "Patient", + "name": "Patient", + "kind": "resource", + "elements": { + "resourceType": { "type": "string" }, + "id": { "type": "string" } + } + }, + "Observation": { + "type": "Observation", + "name": "Observation", + "kind": "resource", + "elements": { + "resourceType": { "type": "string" }, + "id": { "type": "string" }, + "status": { "type": "code" } + } + }, + "http://example.org/StructureDefinition/ext-race": { + "kind": "extension", + "url": "http://example.org/StructureDefinition/ext-race", + "elements": { + "url": { "type": "uri" }, + "value": { "choices": ["valueCode"] }, + "valueCode": { "choiceOf": "value", "type": "code" } + } + }, + "http://example.org/StructureDefinition/ext-religion": { + "kind": "extension", + "url": "http://example.org/StructureDefinition/ext-religion", + "elements": { + "url": { "type": "uri" }, + "value": { "choices": ["valueString"] }, + "valueString": { "choiceOf": "value", "type": "string" } + } + }, + + "TypeSliceProfile": { + "base": "BundleLike", + "elements": { + "entry": { + "slicing": { + "rules": "closed", + "slices": { + "patient": { + "match": { "type": "type", "value": "Patient" }, + "min": 1, + "max": 1, + "schema": { + "required": ["id"], + "elements": { "id": { "type": "string" } } + } + }, + "observation": { + "match": { "type": "type", "value": "Observation" }, + "min": 0, + "max": 1 + } + } + } + } + } + }, + "BundleLike": { + "elements": { + "resourceType": { "type": "string" }, + "entry": { + "array": true, + "elements": { + "resourceType": { "type": "string" }, + "id": { "type": "string" }, + "status": { "type": "code" } + } + } + } + }, + + "ProfileMetaSlice": { + "base": "ResourceWithContained", + "elements": { + "contained": { + "slicing": { + "slices": { + "typedPatient": { + "match": { + "type": "profile", + "value": "http://example.org/StructureDefinition/mini-patient" + }, + "min": 1, + "max": 1 + } + } + } + } + } + }, + "ResourceWithContained": { + "elements": { + "resourceType": { "type": "string" }, + "contained": { + "array": true, + "elements": { + "resourceType": { "type": "string" }, + "id": { "type": "string" }, + "status": { "type": "code" }, + "meta": { + "elements": { + "profile": { "array": true } + } + } + } + } + } + }, + "http://example.org/StructureDefinition/mini-patient": { + "url": "http://example.org/StructureDefinition/mini-patient", + "type": "Patient", + "name": "MiniPatient", + "kind": "resource" + }, + + "ExtensionProfileSlice": { + "base": "ResourceWithExt", + "elements": { + "extension": { + "slicing": { + "slices": { + "race": { + "match": { + "type": "profile", + "value": "http://example.org/StructureDefinition/ext-race" + }, + "min": 1, + "max": 1, + "schema": { + "required": ["valueCode"], + "elements": { + "valueCode": { "type": "code" } + } + } + } + } + } + } + } + }, + "ResourceWithExt": { + "elements": { + "resourceType": { "type": "string" }, + "extension": { "type": "Extension", "array": true } + } + }, + + "ExtensionSugarProfile": { + "base": "ResourceWithExt", + "extensions": { + "race": { + "url": "http://example.org/StructureDefinition/ext-race", + "min": 1, + "max": 1 + }, + "religion": { + "url": "http://example.org/StructureDefinition/ext-religion", + "min": 0, + "max": 1 + } + } + }, + + "ClosedExtensionProfile": { + "base": "ResourceWithExt", + "elements": { + "extension": { + "slicing": { + "rules": "closed", + "slices": { + "race": { + "match": { + "type": "pattern", + "value": { "url": "http://example.org/StructureDefinition/ext-race" } + }, + "min": 1, + "max": 1, + "schema": { + "required": ["valueCode"] + } + } + } + } + } + } + }, + + "BindingCodeSlice": { + "base": "ResourceWithScalarCodes", + "elements": { + "category": { + "slicing": { + "slices": { + "lab": { + "match": { "type": "binding", "value": "laboratory" }, + "min": 1, + "max": 1 + }, + "vital": { + "match": { "type": "binding", "value": "vital-signs" }, + "min": 0, + "max": 1 + } + } + } + } + } + }, + "ResourceWithScalarCodes": { + "elements": { + "resourceType": { "type": "string" }, + "category": { "array": true } + } + }, + "ResourceWithCodes": { + "elements": { + "resourceType": { "type": "string" }, + "category": { + "array": true, + "elements": { + "system": { "type": "uri" }, + "code": { "type": "code" }, + "text": { "type": "string" }, + "coding": { + "array": true, + "elements": { + "system": { "type": "uri" }, + "code": { "type": "code" } + } + } + } + } + } + }, + + "BindingCodeObjectSlice": { + "base": "ResourceWithCodes", + "elements": { + "category": { + "slicing": { + "slices": { + "lab": { + "match": { "type": "binding", "value": "laboratory" }, + "min": 1, + "max": 1 + }, + "vital": { + "match": { "type": "binding", "value": "vital-signs" }, + "min": 0, + "max": 1 + } + } + } + } + } + }, + + "BindingValueSetSlice": { + "base": "ResourceWithScalarCodes", + "elements": { + "category": { + "slicing": { + "slices": { + "fromVs": { + "match": { + "type": "binding", + "value": "http://hl7.org/fhir/ValueSet/observation-category" + }, + "min": 1 + } + } + } + } + } + }, + + "BindingValueSetObjectSlice": { + "base": "ResourceWithCodes", + "elements": { + "category": { + "slicing": { + "slices": { + "fromVs": { + "match": { + "type": "binding", + "value": "http://hl7.org/fhir/ValueSet/observation-category" + }, + "min": 1 + } + } + } + } + } + }, + + "CodingTypeSlice": { + "base": "ResourceWithCodes", + "elements": { + "category": { + "slicing": { + "rules": "closed", + "slices": { + "coding": { + "match": { "type": "type", "value": "Coding" }, + "min": 1 + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "type: Patient + Observation entries match", + "schemas": ["TypeSliceProfile"], + "data": { + "resourceType": "BundleLike", + "entry": [ + { "resourceType": "Patient", "id": "p1" }, + { "resourceType": "Observation", "id": "o1", "status": "final" } + ] + } + }, + { + "desc": "type: missing required Patient slice", + "schemas": ["TypeSliceProfile"], + "data": { + "resourceType": "BundleLike", + "entry": [{ "resourceType": "Observation", "id": "o1" }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "BundleLike.entry", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "type: Patient slice schema requires id", + "schemas": ["TypeSliceProfile"], + "data": { + "resourceType": "BundleLike", + "entry": [{ "resourceType": "Patient" }] + }, + "errors": [ + { + "type": "required", + "path": "BundleLike.entry.0.id", + "message": "id is required" + } + ] + }, + { + "desc": "type: closed rejects non-Patient/Observation", + "schemas": ["TypeSliceProfile"], + "data": { + "resourceType": "BundleLike", + "entry": [ + { "resourceType": "Patient", "id": "p1" }, + { "resourceType": "Practitioner", "id": "x" } + ] + }, + "errors": [ + { + "type": "slice-unmatched", + "path": "BundleLike.entry.1", + "message": "item does not match any slice in closed slicing" + } + ] + }, + { + "desc": "type: Coding inferred from system+code", + "schemas": ["CodingTypeSlice"], + "data": { + "resourceType": "ResourceWithCodes", + "category": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory" }] + } + }, + { + "desc": "type: plain object is not Coding under closed type slicing", + "schemas": ["CodingTypeSlice"], + "data": { + "resourceType": "ResourceWithCodes", + "category": [{ "text": "lab" }] + }, + "errors": [ + { + "type": "slice-unmatched", + "path": "ResourceWithCodes.category.0", + "message": "item does not match any slice in closed slicing" + }, + { + "type": "slice-cardinality", + "path": "ResourceWithCodes.category", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + + { + "desc": "profile: meta.profile claim matches", + "schemas": ["ProfileMetaSlice"], + "data": { + "resourceType": "ResourceWithContained", + "contained": [ + { + "resourceType": "Patient", + "meta": { + "profile": ["http://example.org/StructureDefinition/mini-patient"] + } + } + ] + } + }, + { + "desc": "profile: resolvable schema type matches resourceType", + "schemas": ["ProfileMetaSlice"], + "data": { + "resourceType": "ResourceWithContained", + "contained": [{ "resourceType": "Patient", "id": "p1" }] + } + }, + { + "desc": "profile: Observation does not match Patient profile slice", + "schemas": ["ProfileMetaSlice"], + "data": { + "resourceType": "ResourceWithContained", + "contained": [{ "resourceType": "Observation", "status": "final" }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithContained.contained", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + + { + "desc": "profile: extension url equals profile canonical", + "schemas": ["ExtensionProfileSlice"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "2028-9" + } + ] + } + }, + { + "desc": "profile: wrong extension url does not satisfy required slice", + "schemas": ["ExtensionProfileSlice"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-religion", + "valueString": "none" + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithExt.extension", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "profile: matched extension slice schema requires valueCode", + "schemas": ["ExtensionProfileSlice"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race" + } + ] + }, + "errors": [ + { + "type": "required", + "path": "ResourceWithExt.extension.0.valueCode", + "message": "valueCode is required" + } + ] + }, + + { + "desc": "extensions sugar: required race present; optional religion allowed", + "schemas": ["ExtensionSugarProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "2028-9" + }, + { + "url": "http://example.org/StructureDefinition/ext-religion", + "valueString": "none" + }, + { + "url": "http://example.org/other", + "valueString": "open slicing allows unknown" + } + ] + } + }, + { + "desc": "extensions sugar: missing required race", + "schemas": ["ExtensionSugarProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-religion", + "valueString": "none" + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithExt.extension", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "extensions sugar: resolved extension schema rejects excluded choice", + "schemas": ["ExtensionSugarProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueString": "wrong" + } + ] + }, + "errors": [ + { + "type": "choice-excluded", + "path": "ResourceWithExt.extension.0.valueString", + "message": "valueString is excluded choice" + } + ] + }, + { + "desc": "extensions sugar: duplicate race exceeds max 1", + "schemas": ["ExtensionSugarProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "a" + }, + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "b" + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithExt.extension", + "message": "Slice defines the following max cardinality: '1', actual cardinality: '2'" + } + ] + }, + + { + "desc": "closed extension pattern: only declared url allowed", + "schemas": ["ClosedExtensionProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "2028-9" + } + ] + } + }, + { + "desc": "closed extension pattern: unknown url rejected", + "schemas": ["ClosedExtensionProfile"], + "data": { + "resourceType": "ResourceWithExt", + "extension": [ + { + "url": "http://example.org/StructureDefinition/ext-race", + "valueCode": "2028-9" + }, + { + "url": "http://example.org/other", + "valueString": "nope" + } + ] + }, + "errors": [ + { + "type": "slice-unmatched", + "path": "ResourceWithExt.extension.1", + "message": "item does not match any slice in closed slicing" + } + ] + }, + + { + "desc": "binding: string code matches", + "schemas": ["BindingCodeSlice"], + "data": { + "resourceType": "ResourceWithScalarCodes", + "category": ["laboratory"] + } + }, + { + "desc": "binding: Coding.code matches", + "schemas": ["BindingCodeObjectSlice"], + "data": { + "resourceType": "ResourceWithCodes", + "category": [ + { "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory" }, + { "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "vital-signs" } + ] + } + }, + { + "desc": "binding: CodeableConcept.coding[].code matches", + "schemas": ["BindingCodeObjectSlice"], + "data": { + "resourceType": "ResourceWithCodes", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory" + } + ] + } + ] + } + }, + { + "desc": "binding: wrong code does not satisfy required slice", + "schemas": ["BindingCodeSlice"], + "data": { + "resourceType": "ResourceWithScalarCodes", + "category": ["survey"] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithScalarCodes.category", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "binding: ValueSet URL does not match Coding from that set (no expansion here)", + "schemas": ["BindingValueSetObjectSlice"], + "data": { + "resourceType": "ResourceWithCodes", + "category": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory" + } + ] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "ResourceWithCodes.category", + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'" + } + ] + }, + { + "desc": "binding: ValueSet URL matches only when item literally carries that URL", + "schemas": ["BindingValueSetSlice"], + "data": { + "resourceType": "ResourceWithScalarCodes", + "category": ["http://hl7.org/fhir/ValueSet/observation-category"] + } + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/value_keywords.json b/crates/fhir-validator/tests/fixtures/extended/value_keywords.json new file mode 100644 index 000000000..29281da37 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/value_keywords.json @@ -0,0 +1,70 @@ +{ + "desc": "maxLength / minValue / maxValue — Helios extended", + "schemas": { + "string": { "kind": "primitive-type" }, + "integer": { "kind": "primitive-type" }, + "date": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": { "type": "string" }, + "name": { "type": "string", "maxLength": 5 }, + "count": { "type": "integer", "minValue": 1, "maxValue": 10 }, + "start": { "type": "date", "minValue": "2020-01-01", "maxValue": "2020-12-31" } + } + } + }, + "tests": [ + { + "desc": "maxLength ok", + "data": { "resourceType": "Resource", "name": "abcde" } + }, + { + "desc": "maxLength exceeded", + "data": { "resourceType": "Resource", "name": "abcdef" }, + "errors": [ + { + "type": "max-length", + "path": "Resource.name", + "message": "string length 6 exceeds maxLength 5" + } + ] + }, + { + "desc": "minValue / maxValue ok", + "data": { "resourceType": "Resource", "count": 5 } + }, + { + "desc": "minValue violated", + "data": { "resourceType": "Resource", "count": 0 }, + "errors": [ + { + "type": "min-value", + "path": "Resource.count", + "message": "value '0' is less than minValue '1'" + } + ] + }, + { + "desc": "maxValue violated", + "data": { "resourceType": "Resource", "count": 11 }, + "errors": [ + { + "type": "max-value", + "path": "Resource.count", + "message": "value '11' is greater than maxValue '10'" + } + ] + }, + { + "desc": "date minValue violated", + "data": { "resourceType": "Resource", "start": "2019-12-31" }, + "errors": [ + { + "type": "min-value", + "path": "Resource.start", + "message": "value '2019-12-31' is less than minValue '2020-01-01'" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/packages/README.md b/crates/fhir-validator/tests/fixtures/packages/README.md new file mode 100644 index 000000000..2520b7e7c --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/README.md @@ -0,0 +1,22 @@ +# Sample FHIR NPM package fixtures + +| Artifact | Purpose | +|----------|---------| +| `sample/` | Expanded source of truth (`package.json` + StructureDefinitions) | +| `sample.tgz` | FHIR NPM tarball (`package/…` layout) used by package validation tests | + +Package id: **`example.fhir.r4.sample@0.1.0`** +Canonical: `http://example.org/fhir/r4/sample` + +Profiles exercise common IG patterns without vendor branding: + +- required demographics (`identifier`, `name`, `gender`) +- identifier slice by `system` (MRN) +- required extension slice (`sample-facility-code`) +- encounter with `targetProfile` on `subject` + +Rebuild the tarball after editing `sample/`: + +```bash +./pack.sh +``` diff --git a/crates/fhir-validator/tests/fixtures/packages/pack.sh b/crates/fhir-validator/tests/fixtures/packages/pack.sh new file mode 100755 index 000000000..f97b530f6 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/pack.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Rebuild sample.tgz from the expanded sample/ directory. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +SAMPLE="$ROOT/sample" +OUT="$ROOT/sample.tgz" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/package" +cp "$SAMPLE"/* "$tmp/package/" + +# Portable tar: GNU and BSD both accept -C + relative paths. +tar -czf "$OUT" -C "$tmp" package +echo "wrote $OUT ($(wc -c <"$OUT") bytes)" diff --git a/crates/fhir-validator/tests/fixtures/packages/sample.tgz b/crates/fhir-validator/tests/fixtures/packages/sample.tgz new file mode 100644 index 000000000..60f4b776c Binary files /dev/null and b/crates/fhir-validator/tests/fixtures/packages/sample.tgz differ diff --git a/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-encounter.json b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-encounter.json new file mode 100644 index 000000000..9d2360d11 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-encounter.json @@ -0,0 +1,46 @@ +{ + "resourceType": "StructureDefinition", + "id": "sample-encounter", + "url": "http://example.org/fhir/r4/sample/StructureDefinition/sample-encounter", + "name": "SampleEncounter", + "title": "Sample Encounter", + "status": "active", + "description": "Encounter profile requiring status, class, and a sample-patient subject.", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Encounter", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Encounter", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Encounter", + "path": "Encounter" + }, + { + "id": "Encounter.status", + "path": "Encounter.status", + "min": 1 + }, + { + "id": "Encounter.class", + "path": "Encounter.class", + "min": 1 + }, + { + "id": "Encounter.subject", + "path": "Encounter.subject", + "min": 1, + "type": [ + { + "code": "Reference", + "targetProfile": [ + "http://example.org/fhir/r4/sample/StructureDefinition/sample-patient" + ] + } + ] + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-facility-code.json b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-facility-code.json new file mode 100644 index 000000000..63f84032e --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-facility-code.json @@ -0,0 +1,51 @@ +{ + "resourceType": "StructureDefinition", + "id": "sample-facility-code", + "url": "http://example.org/fhir/r4/sample/StructureDefinition/sample-facility-code", + "name": "SampleFacilityCode", + "title": "Sample Facility Code", + "status": "active", + "description": "Simple code extension marking the treating facility (test fixture).", + "fhirVersion": "4.0.1", + "kind": "complex-type", + "abstract": false, + "context": [ + { + "type": "element", + "expression": "Patient" + } + ], + "type": "Extension", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Extension", + "path": "Extension", + "min": 0, + "max": "*" + }, + { + "id": "Extension.extension", + "path": "Extension.extension", + "max": "0" + }, + { + "id": "Extension.url", + "path": "Extension.url", + "fixedUri": "http://example.org/fhir/r4/sample/StructureDefinition/sample-facility-code" + }, + { + "id": "Extension.value[x]", + "path": "Extension.value[x]", + "min": 1, + "type": [ + { + "code": "code" + } + ] + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-patient.json b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-patient.json new file mode 100644 index 000000000..8375761f1 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-patient.json @@ -0,0 +1,97 @@ +{ + "resourceType": "StructureDefinition", + "id": "sample-patient", + "url": "http://example.org/fhir/r4/sample/StructureDefinition/sample-patient", + "name": "SamplePatient", + "title": "Sample Patient", + "status": "active", + "description": "Patient profile for validator tests: required identifier/name/gender, MRN identifier slice, and a required facility-code extension slice.", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Patient", + "path": "Patient" + }, + { + "id": "Patient.extension", + "path": "Patient.extension", + "slicing": { + "discriminator": [ + { + "type": "value", + "path": "url" + } + ], + "ordered": false, + "rules": "open" + } + }, + { + "id": "Patient.extension:facilityCode", + "path": "Patient.extension", + "sliceName": "facilityCode", + "min": 1, + "max": "1", + "type": [ + { + "code": "Extension", + "profile": [ + "http://example.org/fhir/r4/sample/StructureDefinition/sample-facility-code" + ] + } + ] + }, + { + "id": "Patient.identifier", + "path": "Patient.identifier", + "min": 1, + "max": "*", + "slicing": { + "discriminator": [ + { + "type": "value", + "path": "system" + } + ], + "rules": "open" + } + }, + { + "id": "Patient.identifier:mrn", + "path": "Patient.identifier", + "sliceName": "mrn", + "min": 1, + "max": "1" + }, + { + "id": "Patient.identifier:mrn.system", + "path": "Patient.identifier.system", + "min": 1, + "fixedUri": "http://example.org/fhir/sid/mrn" + }, + { + "id": "Patient.identifier:mrn.value", + "path": "Patient.identifier.value", + "min": 1 + }, + { + "id": "Patient.name", + "path": "Patient.name", + "min": 1, + "max": "*" + }, + { + "id": "Patient.gender", + "path": "Patient.gender", + "min": 1, + "max": "1" + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/packages/sample/package.json b/crates/fhir-validator/tests/fixtures/packages/sample/package.json new file mode 100644 index 000000000..8b480f371 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/packages/sample/package.json @@ -0,0 +1,17 @@ +{ + "name": "example.fhir.r4.sample", + "version": "0.1.0", + "type": "IG", + "license": "CC0-1.0", + "canonical": "http://example.org/fhir/r4/sample", + "title": "Sample FHIR R4 profiles for validator package tests", + "description": "Minimal Implementation Guide package used by helios-fhir-validator tests. Shapes mirror common hospital IG patterns (required demographics, identifier slicing, extension slices) without vendor-specific naming.", + "fhirVersions": [ + "4.0.1" + ], + "dependencies": {}, + "author": "Helios FHIR Server contributors", + "directories": { + "lib": "package" + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/binding-slice.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/binding-slice.json new file mode 100644 index 000000000..2b354ff60 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/binding-slice.json @@ -0,0 +1,37 @@ +{ + "resourceType": "StructureDefinition", + "id": "binding-slice", + "url": "http://example.org/StructureDefinition/binding-slice", + "name": "BindingSlice", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Observation", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Observation", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Observation", "path": "Observation" }, + { + "id": "Observation.category", + "path": "Observation.category", + "max": "*", + "slicing": { + "discriminator": [{ "type": "binding", "path": "$this" }], + "rules": "open" + } + }, + { + "id": "Observation.category:laboratory", + "path": "Observation.category", + "sliceName": "laboratory", + "min": 1, + "max": "1", + "binding": { + "strength": "required", + "valueSet": "laboratory" + } + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/exists-extension-discriminators.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/exists-extension-discriminators.json new file mode 100644 index 000000000..df62ed9ad --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/exists-extension-discriminators.json @@ -0,0 +1,93 @@ +{ + "resourceType": "StructureDefinition", + "id": "exists-extension-discriminators", + "url": "http://example.org/StructureDefinition/exists-extension-discriminators", + "name": "ExistsExtensionDiscriminators", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Observation", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Observation", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Observation", "path": "Observation" }, + { + "id": "Observation.component", + "path": "Observation.component", + "slicing": { + "discriminator": [{ "type": "exists", "path": "value" }], + "rules": "open" + } + }, + { + "id": "Observation.component:withValue", + "path": "Observation.component", + "sliceName": "withValue", + "min": 1, + "max": "*" + }, + { + "id": "Observation.component:withValue.value[x]", + "path": "Observation.component.value[x]", + "min": 1, + "type": [{ "code": "Quantity" }] + }, + { + "id": "Observation.component:noValue", + "path": "Observation.component", + "sliceName": "noValue", + "min": 0, + "max": "*" + }, + { + "id": "Observation.component:noValue.value[x]", + "path": "Observation.component.value[x]", + "max": "0" + }, + { + "id": "Observation.identifier", + "path": "Observation.identifier", + "slicing": { + "discriminator": [ + { "type": "value", "path": "extension('http://example.org/ext-kind').value" } + ], + "rules": "open" + } + }, + { + "id": "Observation.identifier:kindA", + "path": "Observation.identifier", + "sliceName": "kindA", + "min": 0, + "max": "1" + }, + { + "id": "Observation.identifier:kindA.extension", + "path": "Observation.identifier.extension", + "slicing": { + "discriminator": [{ "type": "value", "path": "url" }], + "rules": "open" + } + }, + { + "id": "Observation.identifier:kindA.extension:kind", + "path": "Observation.identifier.extension", + "sliceName": "kind", + "min": 1, + "max": "1" + }, + { + "id": "Observation.identifier:kindA.extension:kind.url", + "path": "Observation.identifier.extension.url", + "fixedUri": "http://example.org/ext-kind" + }, + { + "id": "Observation.identifier:kindA.extension:kind.value[x]", + "path": "Observation.identifier.extension.value[x]", + "type": [{ "code": "string" }], + "fixedString": "A" + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/slice-discriminators.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/slice-discriminators.json new file mode 100644 index 000000000..5d910b47b --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/slice-discriminators.json @@ -0,0 +1,81 @@ +{ + "resourceType": "StructureDefinition", + "id": "slice-discriminators", + "url": "http://example.org/StructureDefinition/slice-discriminators", + "name": "SliceDiscriminators", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Bundle", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Bundle", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Bundle", "path": "Bundle" }, + { + "id": "Bundle.entry", + "path": "Bundle.entry", + "slicing": { + "discriminator": [{ "type": "type", "path": "resource" }], + "rules": "closed", + "ordered": false + } + }, + { + "id": "Bundle.entry:patient", + "path": "Bundle.entry", + "sliceName": "patient", + "min": 1, + "max": "1" + }, + { + "id": "Bundle.entry:patient.resource", + "path": "Bundle.entry.resource", + "min": 1, + "type": [{ "code": "Patient" }] + }, + { + "id": "Bundle.entry:observation", + "path": "Bundle.entry", + "sliceName": "observation", + "min": 0, + "max": "1" + }, + { + "id": "Bundle.entry:observation.resource", + "path": "Bundle.entry.resource", + "type": [ + { + "code": "Observation", + "profile": ["http://example.org/StructureDefinition/vital-observation"] + } + ] + }, + { + "id": "Bundle.identifier", + "path": "Bundle.identifier", + "slicing": { + "discriminator": [{ "type": "profile", "path": "assigner" }], + "rules": "open" + } + }, + { + "id": "Bundle.identifier:org", + "path": "Bundle.identifier", + "sliceName": "org", + "min": 0, + "max": "1" + }, + { + "id": "Bundle.identifier:org.assigner", + "path": "Bundle.identifier.assigner", + "type": [ + { + "code": "Reference", + "targetProfile": ["http://example.org/StructureDefinition/org-ref"] + } + ] + } + ] + } +} diff --git a/crates/fhir-validator/tests/pack_smoke.rs b/crates/fhir-validator/tests/pack_smoke.rs index aa6ce5e5c..56696b151 100644 --- a/crates/fhir-validator/tests/pack_smoke.rs +++ b/crates/fhir-validator/tests/pack_smoke.rs @@ -22,6 +22,20 @@ use helios_fhir_validator::packs::core_registry; use helios_fhir_validator::{SchemaResolver, ValidationOptions, Validator}; use serde_json::json; +/// Default (R4) pack loads and validates a minimal Patient — kept in the +/// normal test suite so regressions surface without `--ignored`. +#[test] +fn r4_pack_loads_and_validates() { + let registry = core_registry(FhirVersion::R4); + assert!(registry.resolve("Patient").is_some()); + let validator = Validator::new(registry); + let outcome = validator.validate_sync( + &json!({ "resourceType": "Patient", "active": true }), + &ValidationOptions::default(), + ); + assert_eq!(outcome.errors, vec![]); +} + /// Every enabled version's pack loads and validates a minimal Patient. #[test] fn all_enabled_packs_load_and_validate() { diff --git a/crates/fhir-validator/tests/packages_tests.rs b/crates/fhir-validator/tests/packages_tests.rs new file mode 100644 index 000000000..dad9b47e8 --- /dev/null +++ b/crates/fhir-validator/tests/packages_tests.rs @@ -0,0 +1,450 @@ +//! FHIR NPM package cache, dependency resolution, and materialization. + +use flate2::Compression; +use flate2::write::GzEncoder; +use helios_fhir::FhirVersion; +use helios_fhir_validator::{ + CompositeResolver, PackageCache, PackageRef, SchemaResolver, ValidationOptions, Validator, + materialize_package, materialize_package_layers, packs, resolve_packages, +}; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tar::{Builder, Header}; + +fn write_tgz(path: &Path, files: &[(&str, &[u8])]) { + let file = fs::File::create(path).unwrap(); + let enc = GzEncoder::new(file, Compression::default()); + let mut archive = Builder::new(enc); + for (name, data) in files { + let mut header = Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, *name, *data).unwrap(); + } + let enc = archive.into_inner().unwrap(); + enc.finish().unwrap(); +} + +fn minimal_patient_profile_sd() -> Vec { + serde_json::to_vec(&json!({ + "resourceType": "StructureDefinition", + "url": "http://example.org/fhir/StructureDefinition/example-patient", + "name": "ExamplePatient", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Patient", + "path": "Patient", + "min": 0, + "max": "*" + }, + { + "id": "Patient.active", + "path": "Patient.active", + "min": 1, + "max": "1" + } + ] + } + })) + .unwrap() +} + +fn dep_extension_sd() -> Vec { + serde_json::to_vec(&json!({ + "resourceType": "StructureDefinition", + "url": "http://example.org/fhir/StructureDefinition/example-ext", + "name": "ExampleExt", + "status": "active", + "kind": "complex-type", + "abstract": false, + "type": "Extension", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Extension", "path": "Extension", "min": 0, "max": "*" }, + { + "id": "Extension.url", + "path": "Extension.url", + "fixedUri": "http://example.org/fhir/StructureDefinition/example-ext" + } + ] + } + })) + .unwrap() +} + +fn seed_dep_and_root(cache_root: &Path) -> PathBuf { + let tgz_dir = cache_root.join("_tgz"); + fs::create_dir_all(&tgz_dir).unwrap(); + + let dep_tgz = tgz_dir.join("dep.tgz"); + write_tgz( + &dep_tgz, + &[ + ( + "package/package.json", + br#"{ + "name": "example.dep", + "version": "1.0.0", + "fhirVersions": ["4.0.1"], + "dependencies": {} + }"#, + ), + ( + "package/StructureDefinition-example-ext.json", + &dep_extension_sd(), + ), + ], + ); + + let root_tgz = tgz_dir.join("root.tgz"); + write_tgz( + &root_tgz, + &[ + ( + "package/package.json", + br#"{ + "name": "example.ig", + "version": "1.0.0", + "fhirVersions": ["4.0.1"], + "dependencies": { "example.dep": "1.0.0" } + }"#, + ), + ( + "package/StructureDefinition-example-patient.json", + &minimal_patient_profile_sd(), + ), + // Poison abstract root — must be skipped, not abort materialize. + ( + "package/StructureDefinition-Element.json", + br#"{ + "resourceType": "StructureDefinition", + "url": "http://hl7.org/fhir/StructureDefinition/Element", + "name": "Element", + "status": "active", + "kind": "complex-type", + "abstract": true, + "type": "Element", + "differential": { "element": [] } + }"#, + ), + ], + ); + + let cache = PackageCache::new(cache_root); + cache.ensure_from_tgz(&dep_tgz).unwrap(); + cache.ensure_from_tgz(&root_tgz).unwrap(); + tgz_dir +} + +#[test] +fn ensure_from_path_accepts_tgz_and_publisher_output_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cache = PackageCache::new(tmp.path().join("cache")); + + let tgz = tmp.path().join("solo.tgz"); + write_tgz( + &tgz, + &[( + "package/package.json", + br#"{"name":"path.pkg","version":"1.2.3","dependencies":{}}"#, + )], + ); + assert_eq!( + cache.ensure_from_path(&tgz).unwrap().to_string(), + "path.pkg@1.2.3" + ); + + // Simulate IG publisher output/: HTML junk + package.tgz. + let output = tmp.path().join("output"); + fs::create_dir_all(&output).unwrap(); + fs::write(output.join("index.html"), b"").unwrap(); + let package_tgz = output.join("package.tgz"); + write_tgz( + &package_tgz, + &[( + "package/package.json", + br#"{"name":"ig.output","version":"0.1.0","dependencies":{}}"#, + )], + ); + assert_eq!( + cache.ensure_from_path(&output).unwrap().to_string(), + "ig.output@0.1.0" + ); +} + +#[test] +fn ensure_from_path_rejects_ambiguous_output_tarballs() { + let tmp = tempfile::tempdir().unwrap(); + let cache = PackageCache::new(tmp.path().join("cache")); + let output = tmp.path().join("output"); + fs::create_dir_all(&output).unwrap(); + write_tgz( + &output.join("a.tgz"), + &[( + "package/package.json", + br#"{"name":"a","version":"1.0.0","dependencies":{}}"#, + )], + ); + write_tgz( + &output.join("b.tgz"), + &[( + "package/package.json", + br#"{"name":"b","version":"1.0.0","dependencies":{}}"#, + )], + ); + let err = cache.ensure_from_path(&output).unwrap_err(); + assert!( + err.to_string().contains("multiple package tarballs"), + "{err}" + ); +} + +#[test] +fn cache_ensure_from_tgz_and_get() { + let tmp = tempfile::tempdir().unwrap(); + let cache = PackageCache::new(tmp.path()); + let tgz = tmp.path().join("pkg.tgz"); + write_tgz( + &tgz, + &[ + ( + "package/package.json", + br#"{"name":"solo.pkg","version":"0.1.0","dependencies":{}}"#, + ), + ( + "package/StructureDefinition-example-patient.json", + &minimal_patient_profile_sd(), + ), + ], + ); + let id = cache.ensure_from_tgz(&tgz).unwrap(); + assert_eq!(id.to_string(), "solo.pkg@0.1.0"); + let dir = cache.get(&id).unwrap(); + assert!(dir.join("package.json").is_file()); + assert!(dir.join(".sha256").is_file()); +} + +#[test] +fn resolve_requires_deps_in_cache() { + let tmp = tempfile::tempdir().unwrap(); + seed_dep_and_root(tmp.path()); + let cache = PackageCache::new(tmp.path()); + + let missing = resolve_packages( + &cache, + &[PackageRef::parse("example.ig@1.0.0").unwrap()], + Some(FhirVersion::R4), + ); + // dep was seeded — should succeed with dep then root + let ok = missing.unwrap(); + assert_eq!(ok.len(), 2); + assert_eq!(ok[0].id.to_string(), "example.dep@1.0.0"); + assert_eq!(ok[1].id.to_string(), "example.ig@1.0.0"); + + // Fresh cache with only root → missing dep + let tmp2 = tempfile::tempdir().unwrap(); + let cache2 = PackageCache::new(tmp2.path()); + let tgz = tmp2.path().join("root.tgz"); + write_tgz( + &tgz, + &[( + "package/package.json", + br#"{ + "name": "example.ig", + "version": "1.0.0", + "dependencies": { "example.dep": "1.0.0" } + }"#, + )], + ); + cache2.ensure_from_tgz(&tgz).unwrap(); + let err = resolve_packages( + &cache2, + &[PackageRef::parse("example.ig@1.0.0").unwrap()], + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("example.dep@1.0.0"), "{err}"); +} + +#[test] +fn resolve_rejects_incompatible_fhir_versions() { + let tmp = tempfile::tempdir().unwrap(); + let cache = PackageCache::new(tmp.path()); + let tgz = tmp.path().join("r5-only.tgz"); + write_tgz( + &tgz, + &[( + "package/package.json", + br#"{ + "name": "example.r5.only", + "version": "1.0.0", + "fhirVersions": ["5.0.0"], + "dependencies": {} + }"#, + )], + ); + cache.ensure_from_tgz(&tgz).unwrap(); + let err = resolve_packages( + &cache, + &[PackageRef::parse("example.r5.only@1.0.0").unwrap()], + Some(FhirVersion::R4), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("incompatible"), "{msg}"); + assert!(msg.contains("5.0.0") || msg.contains("4.0.1"), "{msg}"); +} + +#[test] +fn materialize_skips_abstract_and_inserts_profile() { + let tmp = tempfile::tempdir().unwrap(); + seed_dep_and_root(tmp.path()); + let cache = PackageCache::new(tmp.path()); + let dir = cache + .get(&PackageRef::parse("example.ig@1.0.0").unwrap()) + .unwrap(); + let (registry, report) = materialize_package(&dir).unwrap(); + assert!(report.skipped_abstract >= 1); + assert!(report.inserted >= 1); + assert!( + registry + .resolve("http://example.org/fhir/StructureDefinition/example-patient") + .is_some() + ); +} + +fn sample_tgz_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/packages/sample.tgz") +} + +fn sample_dir_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/packages/sample") +} + +#[test] +fn bundled_sample_tgz_materializes_and_validates_extension_slices() { + let tmp = tempfile::tempdir().unwrap(); + let cache = PackageCache::new(tmp.path()); + let id = cache.ensure_from_path(&sample_tgz_path()).unwrap(); + assert_eq!(id.to_string(), "example.fhir.r4.sample@0.1.0"); + + // Expanded sources must stay in sync with the committed tarball. + let dir_id = cache.ensure_from_path(&sample_dir_path()).unwrap(); + assert_eq!(dir_id.to_string(), id.to_string()); + + let layers = + materialize_package_layers(&cache, std::slice::from_ref(&id), Some(FhirVersion::R4)) + .unwrap(); + assert_eq!(layers.len(), 1); + assert!(layers[0].2.inserted >= 3, "report: {:?}", layers[0].2); + + let core = packs::core_registry(FhirVersion::R4); + let mut resolvers: Vec> = layers + .into_iter() + .map(|(_, reg, _)| reg as Arc) + .collect(); + resolvers.push(core); + let validator = Validator::new(Arc::new(CompositeResolver::new(resolvers))); + + let profile = "http://example.org/fhir/r4/sample/StructureDefinition/sample-patient"; + let missing = json!({ + "resourceType": "Patient", + "meta": { "profile": [profile] } + }); + let outcome = validator.validate_sync(&missing, &ValidationOptions::default()); + let text = serde_json::to_string(&outcome.errors).unwrap(); + assert!( + text.contains("identifier") + || text.contains("name") + || text.contains("gender") + || text.contains("slice-cardinality") + || text.contains("facility"), + "expected sample-patient required/slice issues, got {text}" + ); + + let ok = json!({ + "resourceType": "Patient", + "meta": { "profile": [profile] }, + "identifier": [{ + "system": "http://example.org/fhir/sid/mrn", + "value": "MRN-1" + }], + "name": [{ "family": "Doe" }], + "gender": "unknown", + "extension": [{ + "url": "http://example.org/fhir/r4/sample/StructureDefinition/sample-facility-code", + "valueCode": "FAC-1" + }] + }); + let outcome = validator.validate_sync(&ok, &ValidationOptions::default()); + let profile_errors: Vec<_> = outcome + .errors + .iter() + .filter(|e| { + let m = e.message.to_lowercase(); + m.contains("required") + || m.contains("slice") + || e.path.contains("identifier") + || e.path.contains("extension") + || e.path.contains("gender") + || e.path.contains("name") + }) + .collect(); + assert!( + profile_errors.is_empty(), + "valid sample patient should not fail profile constraints: {profile_errors:?}" + ); +} + +#[test] +fn package_layers_overlay_core_for_meta_profile() { + let tmp = tempfile::tempdir().unwrap(); + seed_dep_and_root(tmp.path()); + let cache = PackageCache::new(tmp.path()); + let layers = materialize_package_layers( + &cache, + &[PackageRef::parse("example.ig@1.0.0").unwrap()], + Some(FhirVersion::R4), + ) + .unwrap(); + // Overlay order: root then dep (dependents first). + assert_eq!(layers[0].0.to_string(), "example.ig@1.0.0"); + assert_eq!(layers[1].0.to_string(), "example.dep@1.0.0"); + + let core = packs::core_registry(FhirVersion::R4); + let mut resolvers: Vec> = layers + .into_iter() + .map(|(_, reg, _)| reg as Arc) + .collect(); + resolvers.push(core); + let resolver = Arc::new(CompositeResolver::new(resolvers)); + let validator = Validator::new(resolver); + + // Missing required Patient.active from the example profile. + let resource = json!({ + "resourceType": "Patient", + "meta": { + "profile": ["http://example.org/fhir/StructureDefinition/example-patient"] + } + }); + let outcome = validator.validate_sync(&resource, &ValidationOptions::default()); + assert!( + outcome.errors.iter().any(|e| e.message.contains("active") + || e.path.contains("active") + || e.message.to_lowercase().contains("required")), + "expected required active issue, got {:?}", + outcome.errors + ); +} diff --git a/crates/fhir-validator/tests/refers_enforcement.rs b/crates/fhir-validator/tests/refers_enforcement.rs new file mode 100644 index 000000000..2d0997334 --- /dev/null +++ b/crates/fhir-validator/tests/refers_enforcement.rs @@ -0,0 +1,98 @@ +//! Opt-in `refers` target-type enforcement. + +use helios_fhir_validator::{ + FhirSchema, SchemaRegistry, UnknownProfilePolicy, ValidationOptions, Validator, +}; +use serde_json::json; +use std::sync::Arc; + +#[test] +fn enforce_refers_rejects_disallowed_type() { + let mut registry = SchemaRegistry::new(); + registry.insert_named( + "string", + serde_json::from_value::(json!({ "kind": "primitive-type" })).unwrap(), + ); + registry.insert_named( + "Reference", + serde_json::from_value::(json!({ + "elements": { "reference": { "type": "string" } } + })) + .unwrap(), + ); + registry.insert_named( + "Resource", + serde_json::from_value::(json!({ + "elements": { + "resourceType": { "type": "string" }, + "subject": { "type": "Reference", "refers": ["Patient", "Group"] } + } + })) + .unwrap(), + ); + + let validator = Validator::new(Arc::new(registry)); + let opts = ValidationOptions { + profiles: vec![], + use_meta_profiles: true, + unknown_profile: UnknownProfilePolicy::Error, + enforce_refers: true, + }; + let outcome = validator.validate_sync( + &json!({ + "resourceType": "Resource", + "subject": { "reference": "Organization/1" } + }), + &opts, + ); + assert!( + outcome + .errors + .iter() + .any(|e| e.message.contains("Organization") && e.message.contains("refers")), + "{:?}", + outcome.errors + ); +} + +#[test] +fn enforce_refers_off_by_default() { + let mut registry = SchemaRegistry::new(); + registry.insert_named( + "string", + serde_json::from_value::(json!({ "kind": "primitive-type" })).unwrap(), + ); + registry.insert_named( + "Reference", + serde_json::from_value::(json!({ + "elements": { "reference": { "type": "string" } } + })) + .unwrap(), + ); + registry.insert_named( + "Resource", + serde_json::from_value::(json!({ + "elements": { + "resourceType": { "type": "string" }, + "subject": { "type": "Reference", "refers": ["Patient"] } + } + })) + .unwrap(), + ); + let validator = Validator::new(Arc::new(registry)); + let outcome = validator.validate_sync( + &json!({ + "resourceType": "Resource", + "subject": { "reference": "Organization/1" } + }), + &ValidationOptions::default(), + ); + assert!( + outcome + .errors + .iter() + .all(|e| e.kind != helios_fhir_validator::ErrorKind::ReferenceTarget), + "{:?}", + outcome.errors + ); +} diff --git a/crates/fhir-validator/tests/spec_examples.rs b/crates/fhir-validator/tests/spec_examples.rs index b5e3826f6..b00b9bfff 100644 --- a/crates/fhir-validator/tests/spec_examples.rs +++ b/crates/fhir-validator/tests/spec_examples.rs @@ -232,6 +232,9 @@ fn sweep(version: FhirVersion, version_dir: &str) -> (Manifest, Samples) { // coverage is the Inferno job's business (issue #368). use_meta_profiles: true, unknown_profile: UnknownProfilePolicy::Ignore, + // `refers` enforcement is off for the core-spec sweep, matching the + // default and upstream conformance-suite parity. + ..Default::default() }; // Sort for determinism: readdir order is filesystem-dependent and the diff --git a/crates/rest/Cargo.toml b/crates/rest/Cargo.toml index 1bdf82695..f9379c9ed 100644 --- a/crates/rest/Cargo.toml +++ b/crates/rest/Cargo.toml @@ -105,7 +105,7 @@ aws-sdk-s3 = { version = "1", optional = true } aws-config = { version = "1", optional = true } # HTTP client for terminology server integration (HTS) -reqwest = { version = "0.12", features = ["json", "gzip"] } +reqwest = { version = "0.12", features = ["json", "gzip", "blocking"] } # JWT signing for the bulk-submit outbound SMART Backend Services client. jsonwebtoken = "9" diff --git a/crates/rest/src/config.rs b/crates/rest/src/config.rs index a085546a1..0fc46e852 100644 --- a/crates/rest/src/config.rs +++ b/crates/rest/src/config.rs @@ -41,6 +41,9 @@ //! | `HFS_VALIDATION_TERMINOLOGY_TIMEOUT_MS` | 3000 | Per-check terminology timeout | //! | `HFS_VALIDATION_TERMINOLOGY_FAIL` | open | Terminology outage posture: open (warn) or closed (error) | //! | `HFS_VALIDATION_STORED_PROFILES` | true | Maintain per-tenant profile registries from stored StructureDefinitions | +//! | `HFS_FHIR_PACKAGE_CACHE` | (none) | Curated FHIR NPM package cache root | +//! | `HFS_FHIR_PACKAGE_SOURCES` | (none) | Comma-separated local paths or HTTP(S) URLs (`.tgz`, expanded package dir, or IG publisher `output/`) seeded into the cache at boot | +//! | `HFS_FHIR_PACKAGES` | (none) | Comma-separated `name@version` roots; if empty and sources are set, uses packages installed from sources | //! //! # Example //! @@ -490,6 +493,17 @@ pub struct ValidationConfig { /// Maintain per-tenant profile registries from stored /// StructureDefinitions (updated on StructureDefinition writes). pub stored_profiles: bool, + /// Curated FHIR NPM package cache directory (`HFS_FHIR_PACKAGE_CACHE`). + /// Empty disables package overlays. + pub package_cache: Option, + /// Local paths or HTTP(S) URLs seeded into the cache at boot + /// (`HFS_FHIR_PACKAGE_SOURCES`). Each entry may be a `.tgz`, an expanded + /// package directory, an IG publisher `output/` dir, or a package URL. + pub package_sources: Vec, + /// Comma-separated `name@version` roots (`HFS_FHIR_PACKAGES`). If empty + /// while [`Self::package_sources`] is set, roots are the packages + /// installed from those sources. Requires [`Self::package_cache`]. + pub packages: Vec, } impl Default for ValidationConfig { @@ -504,6 +518,9 @@ impl Default for ValidationConfig { terminology_timeout_ms: 3000, terminology_fail: "open".to_string(), stored_profiles: true, + package_cache: None, + package_sources: Vec::new(), + packages: Vec::new(), } } } @@ -549,6 +566,28 @@ impl ValidationConfig { terminology_fail: std::env::var("HFS_VALIDATION_TERMINOLOGY_FAIL") .unwrap_or(d.terminology_fail), stored_profiles: env_bool("HFS_VALIDATION_STORED_PROFILES", d.stored_profiles), + package_cache: std::env::var("HFS_FHIR_PACKAGE_CACHE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + package_sources: std::env::var("HFS_FHIR_PACKAGE_SOURCES") + .map(|s| { + s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + packages: std::env::var("HFS_FHIR_PACKAGES") + .map(|s| { + s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), } } @@ -582,6 +621,21 @@ impl ValidationConfig { if self.terminology_timeout_ms == 0 { errors.push("HFS_VALIDATION_TERMINOLOGY_TIMEOUT_MS must be > 0".to_string()); } + if (!self.packages.is_empty() || !self.package_sources.is_empty()) + && self.package_cache.is_none() + { + errors.push( + "HFS_FHIR_PACKAGES / HFS_FHIR_PACKAGE_SOURCES require HFS_FHIR_PACKAGE_CACHE" + .to_string(), + ); + } + for pkg in &self.packages { + if helios_fhir_validator::PackageRef::parse(pkg).is_err() { + errors.push(format!( + "HFS_FHIR_PACKAGES entry '{pkg}' invalid (expected name@version)" + )); + } + } if errors.is_empty() { Ok(()) } else { diff --git a/crates/rest/src/state.rs b/crates/rest/src/state.rs index d51c62d10..f79ce8419 100644 --- a/crates/rest/src/state.rs +++ b/crates/rest/src/state.rs @@ -163,11 +163,14 @@ impl AppState { pub fn new(storage: Arc, config: ServerConfig) -> Self { let bulk_export_config = Arc::new(config.bulk_export.clone()); let bulk_submit_config = Arc::new(config.bulk_submit.clone()); - let validation = Arc::new(crate::validation::ValidationService::from_config( - &config.validation, - config.terminology_server.as_deref(), - config.default_fhir_version, - )); + let validation = Arc::new( + crate::validation::ValidationService::from_config( + &config.validation, + config.terminology_server.as_deref(), + config.default_fhir_version, + ) + .unwrap_or_else(|e| panic!("validation service configuration failed: {e}")), + ); Self { storage, config: Arc::new(config), @@ -216,11 +219,14 @@ impl AppState { ) -> Self { let bulk_export_config = Arc::new(config.bulk_export.clone()); let bulk_submit_config = Arc::new(config.bulk_submit.clone()); - let validation = Arc::new(crate::validation::ValidationService::from_config( - &config.validation, - config.terminology_server.as_deref(), - config.default_fhir_version, - )); + let validation = Arc::new( + crate::validation::ValidationService::from_config( + &config.validation, + config.terminology_server.as_deref(), + config.default_fhir_version, + ) + .unwrap_or_else(|e| panic!("validation service configuration failed: {e}")), + ); Self { storage, config: Arc::new(config), diff --git a/crates/rest/src/validation.rs b/crates/rest/src/validation.rs index 386c808e0..116f29d7f 100644 --- a/crates/rest/src/validation.rs +++ b/crates/rest/src/validation.rs @@ -21,14 +21,28 @@ use dashmap::DashMap; use helios_fhir::FhirVersion; use helios_fhir_validator::fhirpath_effects::FhirPathConstraintEvaluator; use helios_fhir_validator::{ - CodedValue, CompositeResolver, EffectHandlers, ErrorKind, SchemaRegistry, SchemaResolver, - Severity, TerminologyError, TerminologyProvider, UnknownProfilePolicy, ValidationError, - ValidationOptions, Validator, dotted_to_fhirpath, + CodedValue, CompositeResolver, EffectHandlers, ErrorKind, PackageCache, PackageId, PackageRef, + SchemaRegistry, SchemaResolver, Severity, TerminologyError, TerminologyProvider, + UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, + materialize_package_layers_by_version, validate_questionnaire_response, }; use serde_json::{Value, json}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; + +/// How terminology bindings are checked. +#[derive(Clone)] +enum TerminologyMode { + Off, + /// Offline core ValueSets — selected per request FHIR version. + Embedded, + /// Remote `$validate-code` (version-agnostic). + Remote(Arc), +} /// Per-tenant, per-version profile registries fed from stored /// StructureDefinitions. @@ -51,7 +65,7 @@ pub enum ValidationMode { pub struct ValidationService { mode: ValidationMode, constraint_evaluator: Option, - terminology: Option>, + terminology: TerminologyMode, /// Constraint ids never evaluated (default: `dom-6`, the narrative /// warning that fires on almost every machine-generated resource). suppress_constraints: Vec, @@ -60,9 +74,24 @@ pub struct ValidationService { /// Validate against `meta.profile` claims. use_meta_profiles: bool, unknown_profile: UnknownProfilePolicy, + /// Opt-in warning enforcement for `extensible`-strength bindings. + extensible_bindings: bool, + /// Enforce `refers` target-type checks on Reference elements. + enforce_refers: bool, /// Per-tenant profile overlays, fed from stored StructureDefinition /// writes. `None` disables the feature. tenant_profiles: Option, + /// Server-wide IG/NPM package registry layers keyed by FHIR version + /// (dependents before deps). Empty when packages are unset. + package_layers: HashMap>>, + /// Optional lookup for Questionnaire resources (QR validation). + questionnaire_lookup: Option>, +} + +/// Resolves a Questionnaire by canonical URL for QuestionnaireResponse checks. +pub trait QuestionnaireLookup: Send + Sync { + /// Return the Questionnaire resource for `canonical`, if available. + fn get_questionnaire(&self, canonical: &str) -> Option; } impl Default for ValidationService { @@ -70,12 +99,16 @@ impl Default for ValidationService { Self { mode: ValidationMode::Off, constraint_evaluator: Some(FhirPathConstraintEvaluator::new()), - terminology: None, + terminology: TerminologyMode::Off, suppress_constraints: vec!["dom-6".to_string()], terminology_fail_closed: false, use_meta_profiles: true, unknown_profile: UnknownProfilePolicy::Warn, + extensible_bindings: false, + enforce_refers: false, tenant_profiles: Some(DashMap::new()), + package_layers: HashMap::new(), + questionnaire_lookup: None, } } } @@ -88,14 +121,17 @@ impl ValidationService { Self::default() } - /// Build a service from `HFS_VALIDATION_*` configuration. + /// Build a service from `HFS_VALIDATION_*` / `HFS_FHIR_*` configuration. /// `terminology_server` is `HFS_TERMINOLOGY_SERVER` (required for /// `terminology = remote`; the config validator enforces the pairing). + /// + /// Fails when `HFS_FHIR_PACKAGES` is set and package resolution or + /// materialization fails — never boots with a silently empty overlay. pub fn from_config( config: &ValidationConfig, terminology_server: Option<&str>, version: helios_fhir::FhirVersion, - ) -> Self { + ) -> Result { let mode = match config.mode.as_str() { "log" => ValidationMode::Log, "enforce" => ValidationMode::Enforce, @@ -106,22 +142,22 @@ impl ValidationService { "ignore" => UnknownProfilePolicy::Ignore, _ => UnknownProfilePolicy::Warn, }; - let terminology: Option> = - match (config.terminology.as_str(), terminology_server) { - ("remote", Some(base)) => Some(Arc::new(RemoteTerminologyProvider::new( + let terminology = match (config.terminology.as_str(), terminology_server) { + ("remote", Some(base)) => { + TerminologyMode::Remote(Arc::new(RemoteTerminologyProvider::new( base.to_string(), Duration::from_millis(config.terminology_timeout_ms), - ))), - // Offline required-binding checks against the FHIR core value - // sets embedded in helios-fhir-validator (no server needed). - ("embedded", _) => { - let provider: Arc = - helios_fhir_validator::core_terminology(version); - Some(provider) - } - _ => None, - }; - Self { + ))) + } + // Offline required-binding checks against the FHIR core value + // sets embedded in helios-fhir-validator (selected per request). + ("embedded", _) => TerminologyMode::Embedded, + _ => TerminologyMode::Off, + }; + + let package_layers = load_package_layers(config, version)?; + + Ok(Self { mode, constraint_evaluator: config.constraints.then(FhirPathConstraintEvaluator::new), terminology, @@ -129,13 +165,23 @@ impl ValidationService { terminology_fail_closed: config.terminology_fail == "closed", use_meta_profiles: config.meta_profiles, unknown_profile, + extensible_bindings: false, + enforce_refers: false, tenant_profiles: config.stored_profiles.then(DashMap::new), - } + package_layers, + questionnaire_lookup: None, + }) } - /// Replace the terminology provider (bindings stay unchecked without one). + /// Replace the terminology provider with a remote/custom one. pub fn with_terminology(mut self, provider: Arc) -> Self { - self.terminology = Some(provider); + self.terminology = TerminologyMode::Remote(provider); + self + } + + /// Install a Questionnaire lookup used for QuestionnaireResponse checks. + pub fn with_questionnaire_lookup(mut self, lookup: Arc) -> Self { + self.questionnaire_lookup = Some(lookup); self } @@ -145,8 +191,9 @@ impl ValidationService { } /// Validate a resource against the core pack for `version` (overlaid - /// with the tenant's stored profiles) plus any extra profile canonicals. - /// Structural issues first, then constraint issues, then binding issues. + /// with package layers and the tenant's stored profiles) plus any extra + /// profile canonicals. Structural issues first, then constraint issues, + /// then binding issues. pub async fn validate_resource( &self, version: FhirVersion, @@ -154,29 +201,67 @@ impl ValidationService { profiles: Vec, tenant: Option<&str>, ) -> Vec { - let core = helios_fhir_validator::packs::core_registry(version); - let resolver: Arc = match self.tenant_overlay(tenant, version) { - Some(overlay) => Arc::new(CompositeResolver::new(vec![overlay, core])), - None => core, - }; - let validator = Validator::new(resolver); + let resolver = self.resolver_for(version, tenant); + let validator = Validator::new(Arc::clone(&resolver)); let opts = ValidationOptions { profiles, use_meta_profiles: self.use_meta_profiles, unknown_profile: self.unknown_profile, + enforce_refers: self.enforce_refers, + }; + let embedded = match &self.terminology { + TerminologyMode::Embedded => Some(helios_fhir_validator::core_terminology(version)), + _ => None, + }; + let terminology: Option<&dyn TerminologyProvider> = match (&self.terminology, &embedded) { + (TerminologyMode::Remote(provider), _) => Some(provider.as_ref()), + (TerminologyMode::Embedded, Some(provider)) => Some(provider.as_ref()), + _ => None, }; let handlers = EffectHandlers { constraints: self .constraint_evaluator .as_ref() .map(|e| e as &dyn helios_fhir_validator::ConstraintEvaluator), - terminology: self.terminology.as_deref(), + terminology, suppress_constraints: &self.suppress_constraints, terminology_fail_closed: self.terminology_fail_closed, + check_extensible_bindings: self.extensible_bindings, }; - validator + let mut issues = validator .validate(resource, version, &opts, &handlers) - .await + .await; + + if resource.get("resourceType").and_then(Value::as_str) == Some("QuestionnaireResponse") { + issues.extend(self.validate_qr(resource, terminology).await); + } + issues + } + + async fn validate_qr( + &self, + qr: &Value, + terminology: Option<&dyn TerminologyProvider>, + ) -> Vec { + let Some(canonical) = qr.get("questionnaire").and_then(Value::as_str) else { + return Vec::new(); + }; + let Some(lookup) = &self.questionnaire_lookup else { + return Vec::new(); + }; + let Some(questionnaire) = lookup.get_questionnaire(canonical) else { + return vec![ + ValidationError::new( + ErrorKind::UnknownSchema, + "QuestionnaireResponse.questionnaire".into(), + format!( + "could not resolve Questionnaire '{canonical}' for response validation" + ), + ) + .with_severity(Severity::Warning), + ]; + }; + validate_questionnaire_response(qr, &questionnaire, terminology).await } /// Write-path gate. `Ok(())` = proceed with the write; `Err` = reject @@ -284,6 +369,126 @@ impl ValidationService { let registry = registries.get(&(tenant.to_string(), version))?.clone(); Some(Arc::new(LockedRegistryResolver(registry))) } + + /// `CompositeResolver` layers: tenant overlay, package layers for + /// `version` (dependents before deps), then the embedded core pack. + fn resolver_for(&self, version: FhirVersion, tenant: Option<&str>) -> Arc { + let core = helios_fhir_validator::packs::core_registry(version); + let mut layers: Vec> = Vec::new(); + if let Some(overlay) = self.tenant_overlay(tenant, version) { + layers.push(overlay); + } + if let Some(pkgs) = self.package_layers.get(&version) { + for pkg in pkgs { + layers.push(Arc::clone(pkg) as Arc); + } + } + if layers.is_empty() { + return core; + } + layers.push(core); + Arc::new(CompositeResolver::new(layers)) + } +} + +fn load_package_layers( + config: &ValidationConfig, + default_version: FhirVersion, +) -> Result>>, String> { + if config.packages.is_empty() && config.package_sources.is_empty() { + return Ok(HashMap::new()); + } + let cache_root = config.package_cache.as_deref().ok_or_else(|| { + "HFS_FHIR_PACKAGES / HFS_FHIR_PACKAGE_SOURCES require HFS_FHIR_PACKAGE_CACHE".to_string() + })?; + let cache = PackageCache::new(cache_root); + + let mut sourced: Vec = Vec::new(); + for raw in &config.package_sources { + let id = seed_package_source(&cache, raw) + .map_err(|e| format!("HFS_FHIR_PACKAGE_SOURCES entry '{raw}': {e}"))?; + info!(package = %id, source = %raw, "seeded FHIR package into cache"); + sourced.push(id); + } + + let roots = if config.packages.is_empty() { + if sourced.is_empty() { + return Ok(HashMap::new()); + } + sourced + } else { + let mut roots = Vec::with_capacity(config.packages.len()); + for raw in &config.packages { + roots.push( + PackageRef::parse(raw) + .map_err(|e| format!("HFS_FHIR_PACKAGES entry '{raw}': {e}"))?, + ); + } + roots + }; + + // The compiled-in releases, straight from helios-fhir — this used to be a + // local cfg-ladder copy, which `--all-features` (where every cfg vanishes) + // tripped `clippy::vec_init_then_push` on. + let versions = FhirVersion::enabled_versions(); + let by_version = + materialize_package_layers_by_version(&cache, &roots, default_version, versions) + .map_err(|e| format!("FHIR package materialization failed: {e}"))?; + + for (version, layers) in &by_version { + info!( + fhir_version = %version.full_version(), + layer_count = layers.len(), + "loaded FHIR package schema layers" + ); + } + Ok(by_version) +} + +/// Seed one source into the cache: local path (via `ensure_from_path`) or +/// HTTP(S) `.tgz` URL (downloaded under `{cache}/.downloads/`). +fn seed_package_source(cache: &PackageCache, raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + let downloaded = download_package_tgz(cache.root(), trimmed)?; + return cache + .ensure_from_tgz(&downloaded) + .map_err(|e| e.to_string()); + } + let path = PathBuf::from(trimmed); + cache.ensure_from_path(&path).map_err(|e| e.to_string()) +} + +fn download_package_tgz(cache_root: &Path, url: &str) -> Result { + let downloads = cache_root.join(".downloads"); + fs::create_dir_all(&downloads) + .map_err(|e| format!("cannot create {}: {e}", downloads.display()))?; + + let name = url + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("package.tgz"); + let name = if name.ends_with(".tgz") || name.ends_with(".tar.gz") { + name.to_string() + } else { + format!("{name}.tgz") + }; + let dest = downloads.join(&name); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| format!("HTTP client: {e}"))?; + let response = client + .get(url) + .send() + .map_err(|e| format!("GET {url}: {e}"))? + .error_for_status() + .map_err(|e| format!("GET {url}: {e}"))?; + let bytes = response.bytes().map_err(|e| format!("read {url}: {e}"))?; + fs::write(&dest, &bytes).map_err(|e| format!("write {}: {e}", dest.display()))?; + Ok(dest) } /// Resolver adapter over a shared, mutable registry. @@ -407,14 +612,17 @@ impl TerminologyProvider for RemoteTerminologyProvider { pub fn to_outcome_issue(error: &ValidationError) -> Issue { let code = match error.kind { ErrorKind::Required => IssueType::Required, - ErrorKind::FixedValue | ErrorKind::PatternValue | ErrorKind::PrimitiveValue => { - IssueType::Value - } + ErrorKind::FixedValue + | ErrorKind::PatternValue + | ErrorKind::PrimitiveValue + | ErrorKind::MaxLength + | ErrorKind::MinValue + | ErrorKind::MaxValue => IssueType::Value, ErrorKind::FhirpathConstraint => IssueType::Invariant, - ErrorKind::TerminologyBinding => IssueType::CodeInvalid, + ErrorKind::TerminologyBinding | ErrorKind::Questionnaire => IssueType::CodeInvalid, ErrorKind::UnknownSchema | ErrorKind::UnknownProfile => IssueType::NotSupported, // Everything structural: unknown-element, shape, cardinality, - // slicing, choices, wrong container type. + // slicing, choices, wrong container type, reference targets. ErrorKind::UnknownElement | ErrorKind::NotArray | ErrorKind::NotSingular @@ -426,7 +634,8 @@ pub fn to_outcome_issue(error: &ValidationError) -> Issue { | ErrorKind::SliceUnmatched | ErrorKind::SliceOrder | ErrorKind::Choice - | ErrorKind::ChoiceExcluded => IssueType::Structure, + | ErrorKind::ChoiceExcluded + | ErrorKind::ReferenceTarget => IssueType::Structure, }; let severity = match error.severity { Severity::Error => IssueSeverity::Error, diff --git a/docs/validation-cutover.md b/docs/validation-cutover.md new file mode 100644 index 000000000..9b16e77ac --- /dev/null +++ b/docs/validation-cutover.md @@ -0,0 +1,35 @@ +# Single-engine validation cutover + +HFS now uses **only** `helios-fhir-validator` for `$validate` and write-path +enforcement. The Atrius `fhir-validation` crate and `HFS_PROFILE_MANIFEST` / +`HFS_PROFILE_VALIDATION_MODE` path have been removed. + +## Operator config + +| Variable | Role | +|----------|------| +| `HFS_FHIR_PACKAGE_CACHE` | Cache root for expanded packages | +| `HFS_FHIR_PACKAGE_SOURCES` | Local `.tgz` / dir / IG `output/`, and/or `https://…/*.tgz` — seeded at boot | +| `HFS_FHIR_PACKAGES` | Optional `name@version` roots (defaults to packages from sources) | +| `HFS_VALIDATION_MODE` | `off` / `log` / `enforce` on create/update/patch/batch/**transaction** | + +See [crates/fhir-validator/docs/packages.md](../crates/fhir-validator/docs/packages.md). + +### Sample package (tests / smoke) + +```bash +export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache +export HFS_FHIR_PACKAGE_SOURCES=crates/fhir-validator/tests/fixtures/packages/sample.tgz +export HFS_VALIDATION_MODE=enforce +``` + +For a full IG publisher tree, prefer `output/package.tgz` (or a single `*.tgz`). +Do **not** treat the whole HTML `output/` tree as a package unless it contains +that tarball. + +## Staging checklist + +1. Set cache + sources (and optional package roots) as above. +2. Smoke `$validate` and HIS write paths (including transaction Bundles). +3. Compare issues against prior dual-engine baselines for Patient / Encounter / Appointment. +4. Seed dependency packages into the same cache if `package.json` deps must resolve.