From 98f609726fc83cbffe372a988a6700ee354fc41b Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 12:05:05 +0530 Subject: [PATCH 01/10] feat(validator): FHIR NPM package cache, deps, and slice matchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add package materialization proper on top of the #232 SchemaRegistry overlay shape: curated cache, offline dependency resolution, and StructureDefinition→schema layers wired into ValidationService via HFS_FHIR_PACKAGE_CACHE / HFS_FHIR_PACKAGES (fail-loud on load errors). Also evaluate type/profile/binding slice matchers in the FHIR Schema engine so IG packages can enforce non-pattern discriminators. Co-authored-by: Cursor --- Cargo.lock | 3 + crates/fhir-validator/Cargo.toml | 5 + crates/fhir-validator/docs/packages.md | 50 ++++ .../fhir-validator/src/converter/slicing.rs | 113 ++++++-- crates/fhir-validator/src/converter/tree.rs | 12 +- crates/fhir-validator/src/engine/slicing.rs | 165 ++++++++++- crates/fhir-validator/src/engine/walk.rs | 2 +- crates/fhir-validator/src/lib.rs | 15 +- crates/fhir-validator/src/packages/cache.rs | 198 +++++++++++++ crates/fhir-validator/src/packages/error.rs | 56 ++++ .../fhir-validator/src/packages/manifest.rs | 108 +++++++ .../src/packages/materialize.rs | 93 ++++++ crates/fhir-validator/src/packages/mod.rs | 86 ++++++ crates/fhir-validator/src/packages/resolve.rs | 99 +++++++ crates/fhir-validator/src/packages/scan.rs | 106 +++++++ crates/fhir-validator/tests/packages_tests.rs | 272 ++++++++++++++++++ crates/rest/src/config.rs | 35 +++ crates/rest/src/state.rs | 26 +- crates/rest/src/validation.rs | 100 ++++++- 19 files changed, 1476 insertions(+), 68 deletions(-) create mode 100644 crates/fhir-validator/docs/packages.md create mode 100644 crates/fhir-validator/src/packages/cache.rs create mode 100644 crates/fhir-validator/src/packages/error.rs create mode 100644 crates/fhir-validator/src/packages/manifest.rs create mode 100644 crates/fhir-validator/src/packages/materialize.rs create mode 100644 crates/fhir-validator/src/packages/mod.rs create mode 100644 crates/fhir-validator/src/packages/resolve.rs create mode 100644 crates/fhir-validator/src/packages/scan.rs create mode 100644 crates/fhir-validator/tests/packages_tests.rs diff --git a/Cargo.lock b/Cargo.lock index c1be29c57..7e20c5266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3354,6 +3354,9 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", "tokio", ] diff --git a/crates/fhir-validator/Cargo.toml b/crates/fhir-validator/Cargo.toml index c268655f9..9d11fe055 100644 --- a/crates/fhir-validator/Cargo.toml +++ b/crates/fhir-validator/Cargo.toml @@ -35,6 +35,10 @@ 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. @@ -46,6 +50,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/docs/packages.md b/crates/fhir-validator/docs/packages.md new file mode 100644 index 000000000..5b3c6ead7 --- /dev/null +++ b/crates/fhir-validator/docs/packages.md @@ -0,0 +1,50 @@ +# 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, dependency resolution, and +operator configuration. + +## Cache layout + +```text +{HFS_FHIR_PACKAGE_CACHE}/{package-name}/{version}/ + package.json + StructureDefinition-….json + … + .sha256 # optional integrity of the source .tgz +``` + +Populate with `PackageCache::ensure_from_tgz` / `ensure_from_dir`, or any +external seed that expands a FHIR NPM `.tgz` (with the `package/` prefix +stripped) into that directory. **Validation never fetches from the network.** + +## Configuration + +| Variable | Purpose | +|----------|---------| +| `HFS_FHIR_PACKAGE_CACHE` | Cache root | +| `HFS_FHIR_PACKAGES` | Comma-separated `name@version` roots | + +If `HFS_FHIR_PACKAGES` is set, boot **fails** when resolution or +materialization fails (no silent empty overlay). + +## Resolver order + +`CompositeResolver` (earlier wins): + +1. Tenant stored-StructureDefinition overlay (optional) +2. Package layers — configured roots / 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`, `resolve_packages`, +`materialize_package`, `materialize_package_layers`. diff --git a/crates/fhir-validator/src/converter/slicing.rs b/crates/fhir-validator/src/converter/slicing.rs index 073a4dad9..7393d13f3 100644 --- a/crates/fhir-validator/src/converter/slicing.rs +++ b/crates/fhir-validator/src/converter/slicing.rs @@ -2,12 +2,11 @@ //! //! 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). +//! +//! `type`, `profile`, and `binding` discriminators become typed [`Match`] +//! values evaluated at runtime by the engine. Paths containing `resolve()` +//! or `extension(...)` remain unsupported (slice kept without match/min). use super::EdDiscriminator; use super::tree::{SliceNode, finalize}; @@ -29,10 +28,10 @@ pub(super) fn build_slicing( node, min, max, - extension_profile: _, + extension_profile, } = slice_node; - let match_ = build_match(&node, discriminators); + let match_ = build_match(&node, discriminators, extension_profile.as_deref()); if match_.is_none() { warnings.push(format!( "slice '{name}': discriminator(s) {:?} not translatable to a match; \ @@ -71,13 +70,81 @@ 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 { +/// Build a match from discriminators, reading constants / type / binding / +/// profile 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; } + if discriminators + .iter() + .any(|d| d.path.contains("resolve()") || d.path.contains("extension(")) + { + return None; + } + + let kinds: Vec<&str> = discriminators.iter().map(|d| d.type_.as_str()).collect(); + let all_pattern = kinds + .iter() + .all(|k| matches!(*k, "value" | "pattern")); + if all_pattern { + return build_pattern_match(node, discriminators); + } + + // Single non-pattern discriminator (homogeneous set of one kind). + if kinds.iter().all(|k| *k == "type") && discriminators.len() == 1 { + let disc = &discriminators[0]; + let target = node_at(node, &disc.path)?; + 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") && discriminators.len() == 1 { + let disc = &discriminators[0]; + let target = node_at(node, &disc.path)?; + 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") && discriminators.len() == 1 { + let disc = &discriminators[0]; + let target = node_at(node, &disc.path)?; + 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, + discriminators: &[EdDiscriminator], +) -> Option { let mut this_constant: Option = None; let mut pattern = Map::new(); @@ -85,9 +152,6 @@ fn build_match(node: &super::tree::Node, discriminators: &[EdDiscriminator]) -> if !matches!(disc.type_.as_str(), "value" | "pattern") { return None; } - if disc.path.contains("resolve()") || disc.path.contains("extension(") { - return None; - } let constant = constant_at(node, &disc.path)?; if disc.path == "$this" { if discriminators.len() > 1 { @@ -111,18 +175,21 @@ fn build_match(node: &super::tree::Node, discriminators: &[EdDiscriminator]) -> }) } +fn node_at<'a>(node: &'a super::tree::Node, path: &str) -> Option<&'a super::tree::Node> { + if path == "$this" { + return Some(node); + } + let mut current = node; + for segment in path.split('.') { + current = current.children.get(segment)?; + } + Some(current) +} + /// 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 - } else { - let mut current = node; - for segment in path.split('.') { - current = current.children.get(segment)?; - } - current - }; + let target = node_at(node, path)?; target .schema .fixed diff --git a/crates/fhir-validator/src/converter/tree.rs b/crates/fhir-validator/src/converter/tree.rs index d45579678..1dcf5012b 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, @@ -223,6 +226,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()); } @@ -234,6 +240,9 @@ 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); @@ -338,9 +347,10 @@ fn apply_choice(parent: &mut Node, base_name: &str, ed: &Ed, warnings: &mut Vec< /// Recursively turn a tree node into a schema, pruning empty nodes. pub(super) fn finalize(node: Node, warnings: &mut Vec) -> Option { - let Node { + let Node { element_name: _, mut schema, + type_profiles: _, children, slices, discriminators, diff --git a/crates/fhir-validator/src/engine/slicing.rs b/crates/fhir-validator/src/engine/slicing.rs index 916417e8f..ed695459d 100644 --- a/crates/fhir-validator/src/engine/slicing.rs +++ b/crates/fhir-validator/src/engine/slicing.rs @@ -19,11 +19,12 @@ //! 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), and +//! `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). +//! `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 +68,7 @@ pub(super) fn validate_slices( if name == DEFAULT_SLICE { continue; } - if slice_matches(slice, item) { + if slice_matches(ctx, slice, item) { *counters.get_mut(name).expect("counter exists") += 1; item_matches[index].push(name.clone()); } @@ -207,16 +208,152 @@ 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). -fn slice_matches(slice: &Slice, item: &Value) -> bool { +/// 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 { return false; }; - match match_.value.as_ref() { - Some(pattern) => is_partial_match(item, pattern), - None => true, + 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), + _ => false, + } +} + +/// 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, } } diff --git a/crates/fhir-validator/src/engine/walk.rs b/crates/fhir-validator/src/engine/walk.rs index 80c472893..e7a147deb 100644 --- a/crates/fhir-validator/src/engine/walk.rs +++ b/crates/fhir-validator/src/engine/walk.rs @@ -81,7 +81,7 @@ 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, diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs index dc3d363d8..83056606f 100644 --- a/crates/fhir-validator/src/lib.rs +++ b/crates/fhir-validator/src/lib.rs @@ -43,10 +43,11 @@ //! //! ## Current limitations (hardening backlog) //! -//! - 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). +//! - Slice matchers: `pattern`, `type`, `profile`, and `binding` are +//! evaluated. `resolve-ref` remains inert. Binding discriminators that +//! name a ValueSet canonical (rather than an inline code) do not expand +//! the ValueSet at mark time. The converter emits a warning when it +//! cannot translate a discriminator into a 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. @@ -65,6 +66,7 @@ pub mod converter; pub mod editor; pub mod effects; pub mod engine; +pub mod packages; pub mod packs; pub mod resolver; pub mod schema; @@ -85,6 +87,11 @@ pub use engine::{ ErrorKind, Severity, SyncOutcome, UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, }; +pub use packages::{ + MaterializeReport, PackageCache, PackageError, PackageId, PackageManifest, PackageRef, + ResolvedPackage, ScannedPackage, materialize_package, materialize_package_layers, + materialize_tgz, resolve_packages, scan_package_dir, +}; 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..fe8052ab2 --- /dev/null +++ b/crates/fhir-validator/src/packages/cache.rs @@ -0,0 +1,198 @@ +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) + } +} + +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..712142c41 --- /dev/null +++ b/crates/fhir-validator/src/packages/error.rs @@ -0,0 +1,56 @@ +use std::fmt; +use std::path::PathBuf; + +/// Errors from package cache, resolution, or materialization. +#[derive(Debug)] +pub enum PackageError { + /// I/O failure while reading or writing the cache. + Io { path: PathBuf, source: std::io::Error }, + /// Archive extract or JSON parse failure. + Invalid(String), + /// Requested package is not present in the cache. + NotInCache { name: String, version: String }, + /// `package.json` missing or malformed. + Manifest(String), + /// Dependency graph problem (missing dep, cycle, FHIR version mismatch). + Resolve(String), + /// StructureDefinition conversion failed hard enough to abort. + Convert(String), +} + +impl fmt::Display for PackageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => { + write!(f, "package I/O error at {}: {source}", path.display()) + } + Self::Invalid(msg) | Self::Manifest(msg) | Self::Resolve(msg) | Self::Convert(msg) => { + write!(f, "{msg}") + } + Self::NotInCache { name, version } => { + write!( + f, + "package {name}@{version} not found in cache (offline resolve)" + ) + } + } + } +} + +impl std::error::Error for PackageError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +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..b7b1c638d --- /dev/null +++ b/crates/fhir-validator/src/packages/manifest.rs @@ -0,0 +1,108 @@ +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..8da827ff9 --- /dev/null +++ b/crates/fhir-validator/src/packages/materialize.rs @@ -0,0 +1,93 @@ +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..888f7515b --- /dev/null +++ b/crates/fhir-validator/src/packages/mod.rs @@ -0,0 +1,86 @@ +//! 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. Validation never +//! fetches from the network; populate the cache with +//! [`PackageCache::ensure_from_tgz`] or [`PackageCache::ensure_from_dir`] +//! (or an external seed step) before resolving. +//! +//! ## 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; + +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}; + +use crate::resolver::SchemaRegistry; +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. +pub fn materialize_package_layers( + cache: &PackageCache, + roots: &[PackageRef], +) -> Result, MaterializeReport)>, PackageError> { + let resolved = resolve_packages(cache, roots)?; + // 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) +} + +/// 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..0e7360bff --- /dev/null +++ b/crates/fhir-validator/src/packages/resolve.rs @@ -0,0 +1,99 @@ +use crate::packages::cache::PackageCache; +use crate::packages::error::PackageError; +use crate::packages::manifest::{PackageId, PackageManifest, PackageRef}; +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. +pub fn resolve_packages( + cache: &PackageCache, + roots: &[PackageRef], +) -> 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"))?; + 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/tests/packages_tests.rs b/crates/fhir-validator/tests/packages_tests.rs new file mode 100644 index 000000000..e5c42c7ad --- /dev/null +++ b/crates/fhir-validator/tests/packages_tests.rs @@ -0,0 +1,272 @@ +//! 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 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()], + ); + // 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()], + ) + .unwrap_err(); + assert!(err.to_string().contains("example.dep@1.0.0"), "{err}"); +} + +#[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() + ); +} + +#[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()], + ) + .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/rest/src/config.rs b/crates/rest/src/config.rs index fe549df57..7de0d1dda 100644 --- a/crates/rest/src/config.rs +++ b/crates/rest/src/config.rs @@ -41,6 +41,8 @@ //! | `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 (offline materialization) | +//! | `HFS_FHIR_PACKAGES` | (none) | Comma-separated `name@version` package roots to overlay on core schemas | //! //! # Example //! @@ -490,6 +492,12 @@ 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, + /// Comma-separated `name@version` roots (`HFS_FHIR_PACKAGES`). Requires + /// [`Self::package_cache`]. Resolution is offline against the cache only. + pub packages: Vec, } impl Default for ValidationConfig { @@ -504,6 +512,8 @@ impl Default for ValidationConfig { terminology_timeout_ms: 3000, terminology_fail: "open".to_string(), stored_profiles: true, + package_cache: None, + packages: Vec::new(), } } } @@ -549,6 +559,19 @@ 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()), + 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 +605,18 @@ 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_cache.is_none() { + errors.push( + "HFS_FHIR_PACKAGES is set but HFS_FHIR_PACKAGE_CACHE is unset".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..d9b8461f5 100644 --- a/crates/rest/src/validation.rs +++ b/crates/rest/src/validation.rs @@ -21,14 +21,15 @@ 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, PackageRef, + SchemaRegistry, SchemaResolver, Severity, TerminologyError, TerminologyProvider, + UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, + materialize_package_layers, }; use serde_json::{Value, json}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; /// Per-tenant, per-version profile registries fed from stored /// StructureDefinitions. @@ -63,6 +64,9 @@ pub struct ValidationService { /// Per-tenant profile overlays, fed from stored StructureDefinition /// writes. `None` disables the feature. tenant_profiles: Option, + /// Server-wide IG/NPM package registry layers (dependents before deps). + /// Empty when `HFS_FHIR_PACKAGES` is unset. + package_layers: Vec>, } impl Default for ValidationService { @@ -76,6 +80,7 @@ impl Default for ValidationService { use_meta_profiles: true, unknown_profile: UnknownProfilePolicy::Warn, tenant_profiles: Some(DashMap::new()), + package_layers: Vec::new(), } } } @@ -88,14 +93,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, @@ -121,7 +129,10 @@ impl ValidationService { } _ => None, }; - Self { + + let package_layers = load_package_layers(config)?; + + Ok(Self { mode, constraint_evaluator: config.constraints.then(FhirPathConstraintEvaluator::new), terminology, @@ -130,7 +141,8 @@ impl ValidationService { use_meta_profiles: config.meta_profiles, unknown_profile, tenant_profiles: config.stored_profiles.then(DashMap::new), - } + package_layers, + }) } /// Replace the terminology provider (bindings stay unchecked without one). @@ -145,8 +157,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,11 +167,7 @@ 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 resolver = self.resolver_for(version, tenant); let validator = Validator::new(resolver); let opts = ValidationOptions { profiles, @@ -284,6 +293,67 @@ impl ValidationService { let registry = registries.get(&(tenant.to_string(), version))?.clone(); Some(Arc::new(LockedRegistryResolver(registry))) } + + /// `CompositeResolver` layers: tenant overlay, package layers (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); + } + for pkg in &self.package_layers { + 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) -> Result>, String> { + if config.packages.is_empty() { + return Ok(Vec::new()); + } + let cache_root = config.package_cache.as_deref().ok_or_else(|| { + "HFS_FHIR_PACKAGES is set but HFS_FHIR_PACKAGE_CACHE is unset".to_string() + })?; + let cache = PackageCache::new(cache_root); + 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}"))?, + ); + } + let layers = materialize_package_layers(&cache, &roots) + .map_err(|e| format!("FHIR package materialization failed: {e}"))?; + + let mut out = Vec::with_capacity(layers.len()); + for (id, registry, report) in layers { + info!( + package = %id, + inserted = report.inserted, + skipped_abstract = report.skipped_abstract, + convert_errors = report.convert_errors.len(), + code_systems = report.code_systems_seen, + value_sets = report.value_sets_seen, + "loaded FHIR package schema layer" + ); + for w in &report.warnings { + warn!(package = %id, "package materialization warning: {w}"); + } + for e in &report.convert_errors { + warn!(package = %id, "package StructureDefinition convert error: {e}"); + } + out.push(registry); + } + Ok(out) } /// Resolver adapter over a shared, mutable registry. From 0849674842f3b2dd1311ac4dbc57bbff8637c88d Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 14:39:22 +0530 Subject: [PATCH 02/10] feat(validator): configurable package sources (path, publisher output, URL) Clarify that cache .staging is only a temp unpack area. Add ensure_from_path for local .tgz, expanded dirs, and IG publisher output/ (preferring package.tgz). Wire HFS_FHIR_PACKAGE_SOURCES so HFS can seed the cache from disk or HTTP(S) at boot without a manual install step. Co-authored-by: Cursor --- crates/fhir-validator/docs/packages.md | 67 ++++++++++----- crates/fhir-validator/src/lib.rs | 4 +- crates/fhir-validator/src/packages/cache.rs | 84 ++++++++++++++++++ crates/fhir-validator/src/packages/mod.rs | 23 ++++- crates/fhir-validator/tests/packages_tests.rs | 63 ++++++++++++++ crates/rest/Cargo.toml | 2 +- crates/rest/src/config.rs | 31 +++++-- crates/rest/src/validation.rs | 85 +++++++++++++++++-- docs/validation-cutover.md | 35 ++++++++ 9 files changed, 353 insertions(+), 41 deletions(-) create mode 100644 docs/validation-cutover.md diff --git a/crates/fhir-validator/docs/packages.md b/crates/fhir-validator/docs/packages.md index 5b3c6ead7..43446c887 100644 --- a/crates/fhir-validator/docs/packages.md +++ b/crates/fhir-validator/docs/packages.md @@ -2,39 +2,66 @@ Package overlays use the same `SchemaRegistry` + `CompositeResolver` path as core packs and tenant-uploaded StructureDefinitions (#232). This document -covers **materialization proper**: cache layout, dependency resolution, and -operator configuration. +covers **materialization proper**: cache layout, sources, dependency +resolution, and operator configuration. -## Cache layout +## Cache vs sources -```text -{HFS_FHIR_PACKAGE_CACHE}/{package-name}/{version}/ - package.json - StructureDefinition-….json - … - .sha256 # optional integrity of the source .tgz -``` +| 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`) -Populate with `PackageCache::ensure_from_tgz` / `ensure_from_dir`, or any -external seed that expands a FHIR NPM `.tgz` (with the `package/` prefix -stripped) into that directory. **Validation never fetches from the network.** +- 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 | -| `HFS_FHIR_PACKAGES` | Comma-separated `name@version` roots | +| `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 + +Publisher tarball on disk: + +```bash +export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache +export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output/atrius.fhir.r4.india.en.tgz +export HFS_VALIDATION_MODE=enforce +# HFS_FHIR_PACKAGES optional — defaults to atrius.fhir.r4.india.en@0.1.0 from the tarball +``` + +Publisher `output/` (picks `package.tgz` if present): + +```bash +export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output +``` + +Published URL: -If `HFS_FHIR_PACKAGES` is set, boot **fails** when resolution or -materialization fails (no silent empty overlay). +```bash +export HFS_FHIR_PACKAGE_SOURCES=https://atrius.in/fhir/r4/atrius-in/package.tgz +``` ## Resolver order `CompositeResolver` (earlier wins): 1. Tenant stored-StructureDefinition overlay (optional) -2. Package layers — configured roots / dependents before transitive deps +2. Package layers — dependents before transitive deps 3. Embedded core schema pack ## What is loaded @@ -46,5 +73,5 @@ via HTS, not the schema registry. ## Library API -See `helios_fhir_validator::packages`: `PackageCache`, `resolve_packages`, -`materialize_package`, `materialize_package_layers`. +See `helios_fhir_validator::packages`: `PackageCache`, `ensure_from_path`, +`resolve_packages`, `materialize_package`, `materialize_package_layers`. diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs index 83056606f..5528f380f 100644 --- a/crates/fhir-validator/src/lib.rs +++ b/crates/fhir-validator/src/lib.rs @@ -89,8 +89,8 @@ pub use engine::{ }; pub use packages::{ MaterializeReport, PackageCache, PackageError, PackageId, PackageManifest, PackageRef, - ResolvedPackage, ScannedPackage, materialize_package, materialize_package_layers, - materialize_tgz, resolve_packages, scan_package_dir, + ResolvedPackage, ScannedPackage, ensure_package_path, materialize_package, + materialize_package_layers, materialize_tgz, resolve_packages, scan_package_dir, }; pub use resolver::{CompositeResolver, SchemaRegistry, SchemaResolver}; pub use schema::{Binding, Constraint, FhirSchema, Match, Slice, Slicing}; diff --git a/crates/fhir-validator/src/packages/cache.rs b/crates/fhir-validator/src/packages/cache.rs index fe8052ab2..d2ea8d170 100644 --- a/crates/fhir-validator/src/packages/cache.rs +++ b/crates/fhir-validator/src/packages/cache.rs @@ -111,6 +111,90 @@ impl PackageCache { 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 { diff --git a/crates/fhir-validator/src/packages/mod.rs b/crates/fhir-validator/src/packages/mod.rs index 888f7515b..a948f4c5f 100644 --- a/crates/fhir-validator/src/packages/mod.rs +++ b/crates/fhir-validator/src/packages/mod.rs @@ -18,10 +18,14 @@ //! ``` //! //! Packages are expanded with the FHIR NPM `package/` prefix stripped so -//! `package.json` sits at the version directory root. Validation never -//! fetches from the network; populate the cache with -//! [`PackageCache::ensure_from_tgz`] or [`PackageCache::ensure_from_dir`] -//! (or an external seed step) before resolving. +//! `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 //! @@ -49,6 +53,17 @@ pub use materialize::{MaterializeReport, materialize_package}; pub use resolve::{ResolvedPackage, resolve_packages}; pub use scan::{ScannedPackage, scan_package_dir}; +/// 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 std::path::Path; use std::sync::Arc; diff --git a/crates/fhir-validator/tests/packages_tests.rs b/crates/fhir-validator/tests/packages_tests.rs index e5c42c7ad..957c87a19 100644 --- a/crates/fhir-validator/tests/packages_tests.rs +++ b/crates/fhir-validator/tests/packages_tests.rs @@ -148,6 +148,69 @@ fn seed_dep_and_root(cache_root: &Path) -> PathBuf { 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(); 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 7de0d1dda..b4f787c24 100644 --- a/crates/rest/src/config.rs +++ b/crates/rest/src/config.rs @@ -41,8 +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 (offline materialization) | -//! | `HFS_FHIR_PACKAGES` | (none) | Comma-separated `name@version` package roots to overlay on core schemas | +//! | `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 //! @@ -495,8 +496,13 @@ pub struct ValidationConfig { /// Curated FHIR NPM package cache directory (`HFS_FHIR_PACKAGE_CACHE`). /// Empty disables package overlays. pub package_cache: Option, - /// Comma-separated `name@version` roots (`HFS_FHIR_PACKAGES`). Requires - /// [`Self::package_cache`]. Resolution is offline against the cache only. + /// 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, } @@ -513,6 +519,7 @@ impl Default for ValidationConfig { terminology_fail: "open".to_string(), stored_profiles: true, package_cache: None, + package_sources: Vec::new(), packages: Vec::new(), } } @@ -563,6 +570,15 @@ impl ValidationConfig { .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(',') @@ -605,9 +621,12 @@ 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_cache.is_none() { + if (!self.packages.is_empty() || !self.package_sources.is_empty()) + && self.package_cache.is_none() + { errors.push( - "HFS_FHIR_PACKAGES is set but HFS_FHIR_PACKAGE_CACHE is unset".to_string(), + "HFS_FHIR_PACKAGES / HFS_FHIR_PACKAGE_SOURCES require HFS_FHIR_PACKAGE_CACHE" + .to_string(), ); } for pkg in &self.packages { diff --git a/crates/rest/src/validation.rs b/crates/rest/src/validation.rs index d9b8461f5..fc73c61b8 100644 --- a/crates/rest/src/validation.rs +++ b/crates/rest/src/validation.rs @@ -21,12 +21,14 @@ use dashmap::DashMap; use helios_fhir::FhirVersion; use helios_fhir_validator::fhirpath_effects::FhirPathConstraintEvaluator; use helios_fhir_validator::{ - CodedValue, CompositeResolver, EffectHandlers, ErrorKind, PackageCache, PackageRef, + CodedValue, CompositeResolver, EffectHandlers, ErrorKind, PackageCache, PackageId, PackageRef, SchemaRegistry, SchemaResolver, Severity, TerminologyError, TerminologyProvider, UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, materialize_package_layers, }; use serde_json::{Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; @@ -318,19 +320,38 @@ impl ValidationService { } fn load_package_layers(config: &ValidationConfig) -> Result>, String> { - if config.packages.is_empty() { + if config.packages.is_empty() && config.package_sources.is_empty() { return Ok(Vec::new()); } let cache_root = config.package_cache.as_deref().ok_or_else(|| { - "HFS_FHIR_PACKAGES is set but HFS_FHIR_PACKAGE_CACHE is unset".to_string() + "HFS_FHIR_PACKAGES / HFS_FHIR_PACKAGE_SOURCES require HFS_FHIR_PACKAGE_CACHE".to_string() })?; let cache = PackageCache::new(cache_root); - 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}"))?, - ); + + 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(Vec::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 + }; + let layers = materialize_package_layers(&cache, &roots) .map_err(|e| format!("FHIR package materialization failed: {e}"))?; @@ -356,6 +377,54 @@ fn load_package_layers(config: &ValidationConfig) -> Result 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. struct LockedRegistryResolver(Arc>); diff --git a/docs/validation-cutover.md b/docs/validation-cutover.md new file mode 100644 index 000000000..a621dd593 --- /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). + +### Atrius IG on disk (publisher) + +```bash +export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache +export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output/atrius.fhir.r4.india.en.tgz +# or: .../output/package.tgz or .../output (uses package.tgz when unique) +export HFS_VALIDATION_MODE=enforce +``` + +Do **not** expect the whole HTML `output/` tree to be scanned as a package unless +it contains `package.tgz` (or a single `*.tgz`). Prefer the `.tgz` path. + +## 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. From 57f1b903d0a82f47b73071c2aa975e3d2700695f Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:07:12 +0530 Subject: [PATCH 03/10] refactor(validator): migrate PackageError to thiserror and add crate README Replace the hand-rolled Display/Error impls with thiserror derives, and give the crate a README (wired via the manifest readme key) so the overview and quick start no longer live only in lib.rs. Co-authored-by: Cursor --- Cargo.lock | 1 + crates/fhir-validator/Cargo.toml | 2 + crates/fhir-validator/README.md | 36 +++++++++++++++++ crates/fhir-validator/src/packages/error.rs | 44 ++++++--------------- 4 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 crates/fhir-validator/README.md diff --git a/Cargo.lock b/Cargo.lock index 7e20c5266..661baafcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,6 +3357,7 @@ dependencies = [ "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 9d11fe055..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] @@ -43,6 +44,7 @@ sha2 = "0.10" 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 } 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/src/packages/error.rs b/crates/fhir-validator/src/packages/error.rs index 712142c41..c7aafffd1 100644 --- a/crates/fhir-validator/src/packages/error.rs +++ b/crates/fhir-validator/src/packages/error.rs @@ -1,51 +1,33 @@ -use std::fmt; use std::path::PathBuf; +use thiserror::Error; /// Errors from package cache, resolution, or materialization. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum PackageError { /// I/O failure while reading or writing the cache. - Io { path: PathBuf, source: std::io::Error }, + #[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 fmt::Display for PackageError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io { path, source } => { - write!(f, "package I/O error at {}: {source}", path.display()) - } - Self::Invalid(msg) | Self::Manifest(msg) | Self::Resolve(msg) | Self::Convert(msg) => { - write!(f, "{msg}") - } - Self::NotInCache { name, version } => { - write!( - f, - "package {name}@{version} not found in cache (offline resolve)" - ) - } - } - } -} - -impl std::error::Error for PackageError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - _ => None, - } - } -} - impl PackageError { pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { Self::Io { From d37f6c57f0782dcabcd4412c834e279739bb01ca Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:07:24 +0530 Subject: [PATCH 04/10] feat(validator): enforce package fhirVersions and materialize layers per FHIR version Resolution now rejects packages whose manifest fhirVersions is incompatible with the target version (full versions, MIME-style 4.0, and R4-style labels all understood), and materialize_package_layers_by_version partitions layers per FhirVersion so overlays from one version never leak into another. Also bundles an anonymized sample.tgz fixture (with pack.sh and sources) exercising cache -> resolve -> materialize -> validate end to end offline, and adds a non-ignored R4 pack smoke test alongside the full feature sweep. Co-authored-by: Cursor --- crates/fhir-validator/docs/packages.md | 15 ++- crates/fhir-validator/src/packages/mod.rs | 66 +++++++++- crates/fhir-validator/src/packages/resolve.rs | 16 +++ crates/fhir-validator/src/packages/version.rs | 59 +++++++++ .../tests/fixtures/packages/README.md | 22 ++++ .../tests/fixtures/packages/pack.sh | 15 +++ .../tests/fixtures/packages/sample.tgz | Bin 0 -> 1332 bytes .../StructureDefinition-sample-encounter.json | 46 +++++++ ...uctureDefinition-sample-facility-code.json | 51 ++++++++ .../StructureDefinition-sample-patient.json | 97 +++++++++++++++ .../fixtures/packages/sample/package.json | 17 +++ crates/fhir-validator/tests/pack_smoke.rs | 16 ++- crates/fhir-validator/tests/packages_tests.rs | 115 ++++++++++++++++++ 13 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 crates/fhir-validator/src/packages/version.rs create mode 100644 crates/fhir-validator/tests/fixtures/packages/README.md create mode 100755 crates/fhir-validator/tests/fixtures/packages/pack.sh create mode 100644 crates/fhir-validator/tests/fixtures/packages/sample.tgz create mode 100644 crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-encounter.json create mode 100644 crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-facility-code.json create mode 100644 crates/fhir-validator/tests/fixtures/packages/sample/StructureDefinition-sample-patient.json create mode 100644 crates/fhir-validator/tests/fixtures/packages/sample/package.json diff --git a/crates/fhir-validator/docs/packages.md b/crates/fhir-validator/docs/packages.md index 43446c887..9817f0073 100644 --- a/crates/fhir-validator/docs/packages.md +++ b/crates/fhir-validator/docs/packages.md @@ -35,27 +35,30 @@ HTTP fetch). They are not package sources. ### Examples -Publisher tarball on disk: +Bundled test fixture (check out of tree): ```bash export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache -export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output/atrius.fhir.r4.india.en.tgz +export HFS_FHIR_PACKAGE_SOURCES=crates/fhir-validator/tests/fixtures/packages/sample.tgz export HFS_VALIDATION_MODE=enforce -# HFS_FHIR_PACKAGES optional — defaults to atrius.fhir.r4.india.en@0.1.0 from the tarball +# defaults to example.fhir.r4.sample@0.1.0 from the tarball ``` -Publisher `output/` (picks `package.tgz` if present): +Publisher tarball or `output/` on disk: ```bash -export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output +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://atrius.in/fhir/r4/atrius-in/package.tgz +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): diff --git a/crates/fhir-validator/src/packages/mod.rs b/crates/fhir-validator/src/packages/mod.rs index a948f4c5f..e0b28ed08 100644 --- a/crates/fhir-validator/src/packages/mod.rs +++ b/crates/fhir-validator/src/packages/mod.rs @@ -45,6 +45,7 @@ mod manifest; mod materialize; mod resolve; mod scan; +mod version; pub use cache::PackageCache; pub use error::PackageError; @@ -52,19 +53,19 @@ 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 { +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; @@ -73,11 +74,15 @@ use std::sync::Arc; /// 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)?; + 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()); @@ -88,6 +93,57 @@ pub fn materialize_package_layers( 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( diff --git a/crates/fhir-validator/src/packages/resolve.rs b/crates/fhir-validator/src/packages/resolve.rs index 0e7360bff..f082437c2 100644 --- a/crates/fhir-validator/src/packages/resolve.rs +++ b/crates/fhir-validator/src/packages/resolve.rs @@ -1,6 +1,8 @@ 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; @@ -16,9 +18,14 @@ pub struct ResolvedPackage { /// `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()); @@ -33,6 +40,15 @@ pub fn resolve_packages( } 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); 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/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 0000000000000000000000000000000000000000..60f4b776cb9b73879165ee2b18979a32cfe31ff2 GIT binary patch literal 1332 zcmV-41uB<*5GX&s;x_3F*W%g3g3=U`eAi z1aG!r+~Z?U=*Lc1@!SI!EQuA{z#Do4vnwA`ee&|cJcrDOSm>?u^FgoR^J$#K%%`EE zETl{){eG@3;q$)I*yrPZS)*h!)RJ!WCi(RF@|s+aNy?KM3qg=s!pS`iSwJQJvl&bz z2q}z&0EEw3Dy`jLSj-|Cl1n8nf>=^TAr~17pg5ih2*~t-EFffw=qbZ`Yb9E^M{X80 zMFS$nId1w%6rr3&B2p%m{0o#OCGc2~Hyr-W7)O}_M9G}fbisVlC8#o%Y{q~Sflg!b zyh~sy(Rs=wwFTlW`H#s(l1Xw8aggwyNWsT5KEZ(D3D4Y8nfQ;1j=*c);vVjfJq%L^ z|KC|xq$mSopMhffvC_LVL-}0!=Ci)8@$n6Cj5b~?mYhv9ym#(TyoisFR)$d&vZ)ea z{iigpj#tM=?fNa24O{<4^30bRhYv8r*F?P-J@Yd5X4GdPlMg*V2@V2?9rb^7a(Y^= z|D(ZZJZkIztJQxFBFVT9f7xojB^gWsV1XiU%L_x#c(Ve(E#3N$i_P)L0Kq5Aa9+?i zkiv41H48>rW~yp+s4v+)Yz2rLrjIMdrD7aW{vE3=kqZPD4hS0fkXuRKC`cePwp6!y zyIGQzTvLlR1tn=8B4(hfWlsaBsl`(vIU0x584U#(+{7A;mK0^()Z7yAf^TnJu5U5!~Y5k_V+ z;0phAKVWerze#@8^<|61t7@?%gxbZUM1)DC4az%oskq;D?AEHgQ=12k^loRf`Ud;x zQ?F62CWisD4}!lrd**j<4~wxw%HVc+S8w(7wkdf6{->sf96lQE!2j{-_{^FA&W2}aE&l)F_;2&#`H;UNu!{LtWs(c`6=EZx zw}$f5*i1Dt`ApAsypH}ZWr}qJ#+Af7 zmZP!4iI<5{R@@J@nb{aWB^e|Jr0ZaclN@8GL_RUebI3Gucm z8g>qX{cW9|G1FP=X%ty@BX*+77s!~)HK7NoW*Ou zOd=ld@4-s?IudHK_CWhQ>zzhsZeiZEs7d25SkPBjJJe`5;&nD}@LH1_>(;1Wqsyh& zyaJ7N8aS`hy2;TXIew4Hqe1@iDI#q&)7 zj>zf^@UcpCrvQiCXbvdO6qA7I+tn97BcMzCkP4Bv_0>s*h-~@|eEAY>ymOr=e448Y zdZ|xy6)o-;pn|yjlTuM^Yyj)TZQ*GOIBOX?zV&K0SoYUqGiAqhHkqFCdF&c=EPc0M q>tMy3gS>j7ZaJ-vZjJin_A_0(+SRUhwX0pPxPAi1j<50nC;$Mg2%yja literal 0 HcmV?d00001 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/pack_smoke.rs b/crates/fhir-validator/tests/pack_smoke.rs index 568f1519d..01ca7db09 100644 --- a/crates/fhir-validator/tests/pack_smoke.rs +++ b/crates/fhir-validator/tests/pack_smoke.rs @@ -10,9 +10,23 @@ 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] -#[ignore = "whole-pack parse; run with -- --ignored"] +#[ignore = "whole-pack parse; run with --features R4B,R5,R6 -- --ignored"] fn all_enabled_packs_load_and_validate() { let versions = [ FhirVersion::R4, diff --git a/crates/fhir-validator/tests/packages_tests.rs b/crates/fhir-validator/tests/packages_tests.rs index 957c87a19..dad9b47e8 100644 --- a/crates/fhir-validator/tests/packages_tests.rs +++ b/crates/fhir-validator/tests/packages_tests.rs @@ -245,6 +245,7 @@ fn resolve_requires_deps_in_cache() { 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(); @@ -271,11 +272,41 @@ fn resolve_requires_deps_in_cache() { 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(); @@ -294,6 +325,89 @@ fn materialize_skips_abstract_and_inserts_profile() { ); } +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(); @@ -302,6 +416,7 @@ fn package_layers_overlay_core_for_meta_profile() { 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). From 58636eaaae946f8c7bd8692a27d2ce12dab57157 Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:07:40 +0530 Subject: [PATCH 05/10] feat(validator): value keywords, refers, extensible bindings, and full discriminator coverage Validation-completeness pass over the engine and converter: - maxLength / minValue[x] / maxValue[x] carried through the converter and enforced in the walk with dedicated error kinds - flag-gated refers (type.targetProfile) enforcement via ValidationOptions::enforce_refers; off by default for suite parity - opt-in warning enforcement for extensible-strength bindings (EffectHandlers::check_extensible_bindings) - reslicing (parent/child) scoped to the parent match, and sliceIsConstraining matcher inheritance - discriminator paths parsed as the restricted-FHIRPath grammar: extension('url') compiles to a containment matcher (nested chains supported), exists discriminators compile to presence/absence matchers read from the slice differential, and ofType()/choice elements resolve to concrete JSON keys; resolve() still degrades gracefully Pinned by converter goldens, extended engine fixtures (value_keywords, reslicing, slice_matchers, exists_extension_matchers), and dedicated refers/extensible-bindings suites. Co-authored-by: Cursor --- .../fhir-validator/src/bin/validator_cli.rs | 1 + crates/fhir-validator/src/converter/mod.rs | 7 +- .../fhir-validator/src/converter/slicing.rs | 422 ++++++++-- crates/fhir-validator/src/converter/tree.rs | 38 +- crates/fhir-validator/src/effects.rs | 19 +- crates/fhir-validator/src/engine/errors.rs | 41 + crates/fhir-validator/src/engine/mod.rs | 4 + crates/fhir-validator/src/engine/slicing.rs | 128 ++- crates/fhir-validator/src/engine/walk.rs | 92 +++ crates/fhir-validator/src/schema.rs | 20 +- crates/fhir-validator/tests/common/mod.rs | 1 + .../fhir-validator/tests/converter_tests.rs | 207 ++++- crates/fhir-validator/tests/extended.rs | 23 +- .../tests/extensible_bindings.rs | 100 +++ .../extended/exists_extension_matchers.json | 301 ++++++++ .../tests/fixtures/extended/refers.json | 39 + .../tests/fixtures/extended/reslicing.json | 71 ++ .../fixtures/extended/slice_matchers.json | 727 ++++++++++++++++++ .../fixtures/extended/value_keywords.json | 70 ++ .../structuredefinitions/binding-slice.json | 37 + .../exists-extension-discriminators.json | 93 +++ .../slice-discriminators.json | 81 ++ .../tests/refers_enforcement.rs | 98 +++ 23 files changed, 2531 insertions(+), 89 deletions(-) create mode 100644 crates/fhir-validator/tests/extensible_bindings.rs create mode 100644 crates/fhir-validator/tests/fixtures/extended/exists_extension_matchers.json create mode 100644 crates/fhir-validator/tests/fixtures/extended/refers.json create mode 100644 crates/fhir-validator/tests/fixtures/extended/reslicing.json create mode 100644 crates/fhir-validator/tests/fixtures/extended/slice_matchers.json create mode 100644 crates/fhir-validator/tests/fixtures/extended/value_keywords.json create mode 100644 crates/fhir-validator/tests/fixtures/structuredefinitions/binding-slice.json create mode 100644 crates/fhir-validator/tests/fixtures/structuredefinitions/exists-extension-discriminators.json create mode 100644 crates/fhir-validator/tests/fixtures/structuredefinitions/slice-discriminators.json create mode 100644 crates/fhir-validator/tests/refers_enforcement.rs 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 90c95bfd8..0d91ee237 100644 --- a/crates/fhir-validator/src/converter/mod.rs +++ b/crates/fhir-validator/src/converter/mod.rs @@ -94,7 +94,12 @@ pub(crate) struct Ed { pub binding: 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 7393d13f3..1baf4b460 100644 --- a/crates/fhir-validator/src/converter/slicing.rs +++ b/crates/fhir-validator/src/converter/slicing.rs @@ -1,18 +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). +//! 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. Paths containing `resolve()` -//! or `extension(...)` remain unsupported (slice kept without match/min). +//! 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,10 +40,16 @@ pub(super) fn build_slicing( min, max, extension_profile, + reslice, + slice_is_constraining, } = slice_node; let match_ = build_match(&node, discriminators, extension_profile.as_deref()); - if match_.is_none() { + // 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", @@ -47,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), }, ); @@ -70,8 +85,182 @@ pub(super) fn build_slicing( }) } +// --------------------------------------------------------------------------- +// 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()) +} + +/// 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 metadata out of the slice subtree. +/// profile / cardinality metadata out of the slice subtree. fn build_match( node: &super::tree::Node, discriminators: &[EdDiscriminator], @@ -81,25 +270,26 @@ fn build_match( return None; } - if discriminators - .iter() - .any(|d| d.path.contains("resolve()") || d.path.contains("extension(")) - { - return None; + let mut parsed: Vec<(&EdDiscriminator, Vec)> = Vec::with_capacity(discriminators.len()); + for disc in discriminators { + let segs = parse_disc_path(&disc.path)?; + if segs.contains(&DSeg::Resolve) { + return None; // needs the instance graph + } + parsed.push((disc, segs)); } let kinds: Vec<&str> = discriminators.iter().map(|d| d.type_.as_str()).collect(); - let all_pattern = kinds - .iter() - .all(|k| matches!(*k, "value" | "pattern")); - if all_pattern { - return build_pattern_match(node, discriminators); + 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") && discriminators.len() == 1 { - let disc = &discriminators[0]; - let target = node_at(node, &disc.path)?; + 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()), @@ -107,9 +297,8 @@ fn build_match( resolve_ref: None, }); } - if kinds.iter().all(|k| *k == "profile") && discriminators.len() == 1 { - let disc = &discriminators[0]; - let target = node_at(node, &disc.path)?; + 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()) @@ -127,9 +316,8 @@ fn build_match( resolve_ref: None, }); } - if kinds.iter().all(|k| *k == "binding") && discriminators.len() == 1 { - let disc = &discriminators[0]; - let target = node_at(node, &disc.path)?; + 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()), @@ -143,23 +331,42 @@ fn build_match( fn build_pattern_match( node: &super::tree::Node, - discriminators: &[EdDiscriminator], + 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; + } + return build_extension_match(node, &discs[0].1); + } + let mut this_constant: Option = None; let mut pattern = Map::new(); - for disc in discriminators { - if !matches!(disc.type_.as_str(), "value" | "pattern") { - return None; - } - let constant = constant_at(node, &disc.path)?; - if disc.path == "$this" { - if discriminators.len() > 1 { + 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); } } @@ -175,41 +382,130 @@ fn build_pattern_match( }) } -fn node_at<'a>(node: &'a super::tree::Node, path: &str) -> Option<&'a super::tree::Node> { - if path == "$this" { - return Some(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; } - let mut current = node; - for segment in path.split('.') { - current = current.children.get(segment)?; + // 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 map = Map::new(); + insert_nested(&mut map, &tail_keys, constant); + Value::Object(map) + }; + + 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); } - Some(current) + 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 at `path` within the slice subtree -/// (`$this` → the subtree root itself). -fn constant_at(node: &super::tree::Node, path: &str) -> Option { - let target = node_at(node, path)?; - target - .schema +/// 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 1dcf5012b..26768b5b6 100644 --- a/crates/fhir-validator/src/converter/tree.rs +++ b/crates/fhir-validator/src/converter/tree.rs @@ -88,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. @@ -115,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" @@ -248,8 +260,9 @@ fn apply_element_content(element: &mut Node, ed: &Ed, warnings: &mut Vec 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 @@ -260,6 +273,9 @@ fn apply_value_keywords(schema: &mut FhirSchema, ed: &Ed, _warnings: &mut Vec) -> Option { - let Node { + let Node { element_name: _, mut schema, type_profiles: _, @@ -466,7 +492,7 @@ fn push_unique(list: &mut 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/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 88741228f..4cebfd88a 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 ed695459d..c208075f8 100644 --- a/crates/fhir-validator/src/engine/slicing.rs +++ b/crates/fhir-validator/src/engine/slicing.rs @@ -20,11 +20,15 @@ //! `max: 0` prohibited slice is enforced (the reference skips falsy bounds). //! //! Match types: `pattern` (partial deep equality), `type` (JSON/FHIR type -//! codes), `profile` (meta.profile claim or resolvable schema type), and +//! 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). -//! `resolve-ref` remains unevaluated. A slice with no `match` matches nothing. +//! 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}; @@ -68,7 +72,7 @@ pub(super) fn validate_slices( if name == DEFAULT_SLICE { continue; } - if slice_matches(ctx, 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()); } @@ -208,6 +212,37 @@ pub(super) fn validate_slices( consumed } +/// 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; + } + slice_matches(ctx, slice, item) +} + /// Does an item belong to a slice? /// /// A missing `match` (constraining slice) matches nothing. A `match` with no @@ -235,10 +270,88 @@ fn slice_matches(ctx: &WalkCtx<'_>, slice: &Slice, item: &Value) -> bool { 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 { @@ -261,7 +374,12 @@ fn json_fhir_types(item: &Value) -> Vec { Value::Bool(_) => vec!["boolean".into()], Value::Number(n) => { if n.is_i64() || n.is_u64() { - vec!["integer".into(), "positiveInt".into(), "unsignedInt".into(), "decimal".into()] + vec![ + "integer".into(), + "positiveInt".into(), + "unsignedInt".into(), + "decimal".into(), + ] } else { vec!["decimal".into()] } diff --git a/crates/fhir-validator/src/engine/walk.rs b/crates/fhir-validator/src/engine/walk.rs index e7a147deb..152633aa4 100644 --- a/crates/fhir-validator/src/engine/walk.rs +++ b/crates/fhir-validator/src/engine/walk.rs @@ -85,6 +85,7 @@ pub(super) struct WalkCtx<'a> { errors: Vec, deferred: Vec, pub(super) path: PathTracker, + enforce_refers: bool, } impl WalkCtx<'_> { @@ -118,6 +119,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 +346,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 +745,9 @@ fn merge_extension_schema(entry: &FhirSchema, resolved: Option<&FhirSchema>) -> choice_of, fixed, pattern, + max_length, + min_value, + max_value, binding, constraints, refers, @@ -731,6 +761,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/schema.rs b/crates/fhir-validator/src/schema.rs index 6e552042c..f3ff5c62f 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. @@ -242,11 +252,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 c276c4449..a9f23a7db 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}; @@ -189,3 +190,201 @@ 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); +} 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/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/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 + ); +} From 12de6fbd5c120510bb7b750aec46b1259b91ec2f Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:07:55 +0530 Subject: [PATCH 06/10] feat(validator): QuestionnaireResponse-vs-Questionnaire validation module Validate a QuestionnaireResponse against its Questionnaire definition: required items, answer type vs item type, answerOption / answerValueSet membership (via the terminology provider), enableWhen gating, and unknown linkIds. Exposed from the crate root alongside restructured lib docs (overview and quick start first, limitations kept current). Co-authored-by: Cursor --- crates/fhir-validator/src/lib.rs | 66 ++-- crates/fhir-validator/src/questionnaire.rs | 418 +++++++++++++++++++++ 2 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 crates/fhir-validator/src/questionnaire.rs diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs index 5528f380f..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,36 +52,34 @@ //! 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: `pattern`, `type`, `profile`, and `binding` are -//! evaluated. `resolve-ref` remains inert. Binding discriminators that -//! name a ValueSet canonical (rather than an inline code) do not expand -//! the ValueSet at mark time. The converter emits a warning when it -//! cannot translate a discriminator into a 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; @@ -68,6 +87,7 @@ pub mod effects; pub mod engine; pub mod packages; pub mod packs; +pub mod questionnaire; pub mod resolver; pub mod schema; pub mod terminology; @@ -89,9 +109,11 @@ pub use engine::{ }; pub use packages::{ MaterializeReport, PackageCache, PackageError, PackageId, PackageManifest, PackageRef, - ResolvedPackage, ScannedPackage, ensure_package_path, materialize_package, - materialize_package_layers, materialize_tgz, resolve_packages, scan_package_dir, + 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/questionnaire.rs b/crates/fhir-validator/src/questionnaire.rs new file mode 100644 index 000000000..593783381 --- /dev/null +++ b/crates/fhir-validator/src/questionnaire.rs @@ -0,0 +1,418 @@ +//! 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 serde_json::json; + + #[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:?}" + ); + } +} From 56f19885885e2ba07302230aa4974fdf3df5e687 Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:07:56 +0530 Subject: [PATCH 07/10] feat(rest): per-version package layers, TerminologyMode, and questionnaire lookup ValidationService now keys package overlays by FhirVersion (loaded through the version-partitioned materializer), replaces the bare optional terminology provider with TerminologyMode (Off/Embedded/Remote), gains a QuestionnaireLookup hook for QuestionnaireResponse checks, and maps the new validator error kinds to OperationOutcome issue types. Co-authored-by: Cursor --- crates/rest/src/validation.rs | 214 +++++++++++++++++++++++----------- docs/validation-cutover.md | 10 +- 2 files changed, 152 insertions(+), 72 deletions(-) diff --git a/crates/rest/src/validation.rs b/crates/rest/src/validation.rs index fc73c61b8..beb102893 100644 --- a/crates/rest/src/validation.rs +++ b/crates/rest/src/validation.rs @@ -24,15 +24,26 @@ use helios_fhir_validator::{ CodedValue, CompositeResolver, EffectHandlers, ErrorKind, PackageCache, PackageId, PackageRef, SchemaRegistry, SchemaResolver, Severity, TerminologyError, TerminologyProvider, UnknownProfilePolicy, ValidationError, ValidationOptions, Validator, dotted_to_fhirpath, - materialize_package_layers, + 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, 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. type TenantProfileMap = DashMap<(String, FhirVersion), Arc>>; @@ -54,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, @@ -63,12 +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 (dependents before deps). - /// Empty when `HFS_FHIR_PACKAGES` is unset. - package_layers: Vec>, + /// 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 { @@ -76,13 +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: Vec::new(), + package_layers: HashMap::new(), + questionnaire_lookup: None, } } } @@ -116,23 +142,20 @@ 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, - }; + ))) + } + // 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)?; + let package_layers = load_package_layers(config, version)?; Ok(Self { mode, @@ -142,14 +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 } @@ -170,24 +202,66 @@ impl ValidationService { tenant: Option<&str>, ) -> Vec { let resolver = self.resolver_for(version, tenant); - let validator = Validator::new(resolver); + 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 @@ -296,20 +370,18 @@ impl ValidationService { Some(Arc::new(LockedRegistryResolver(registry))) } - /// `CompositeResolver` layers: tenant overlay, package layers (dependents - /// before deps), then the embedded core pack. - fn resolver_for( - &self, - version: FhirVersion, - tenant: Option<&str>, - ) -> Arc { + /// `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); } - for pkg in &self.package_layers { - layers.push(Arc::clone(pkg) as Arc); + 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; @@ -319,9 +391,25 @@ impl ValidationService { } } -fn load_package_layers(config: &ValidationConfig) -> Result>, String> { +fn enabled_fhir_versions() -> Vec { + let mut versions = Vec::new(); + #[cfg(feature = "R4")] + versions.push(FhirVersion::R4); + #[cfg(feature = "R4B")] + versions.push(FhirVersion::R4B); + #[cfg(feature = "R5")] + versions.push(FhirVersion::R5); + #[cfg(feature = "R6")] + versions.push(FhirVersion::R6); + versions +} + +fn load_package_layers( + config: &ValidationConfig, + default_version: FhirVersion, +) -> Result>>, String> { if config.packages.is_empty() && config.package_sources.is_empty() { - return Ok(Vec::new()); + 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() @@ -338,7 +426,7 @@ fn load_package_layers(config: &ValidationConfig) -> Result Result Result .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}"))?; + 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) } @@ -546,14 +622,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 @@ -565,7 +644,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 index a621dd593..9b16e77ac 100644 --- a/docs/validation-cutover.md +++ b/docs/validation-cutover.md @@ -15,17 +15,17 @@ enforcement. The Atrius `fhir-validation` crate and `HFS_PROFILE_MANIFEST` / See [crates/fhir-validator/docs/packages.md](../crates/fhir-validator/docs/packages.md). -### Atrius IG on disk (publisher) +### Sample package (tests / smoke) ```bash export HFS_FHIR_PACKAGE_CACHE=$PWD/fhir-package-cache -export HFS_FHIR_PACKAGE_SOURCES=/Users/sandhu/AtriusIGDraft/output/atrius.fhir.r4.india.en.tgz -# or: .../output/package.tgz or .../output (uses package.tgz when unique) +export HFS_FHIR_PACKAGE_SOURCES=crates/fhir-validator/tests/fixtures/packages/sample.tgz export HFS_VALIDATION_MODE=enforce ``` -Do **not** expect the whole HTML `output/` tree to be scanned as a package unless -it contains `package.tgz` (or a single `*.tgz`). Prefer the `.tgz` path. +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 From d804cce73a161002bfebd8705e3cfc86ef665505 Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:08:21 +0530 Subject: [PATCH 08/10] style(validator): rustfmt reflow in packages module Co-authored-by: Cursor --- crates/fhir-validator/src/packages/cache.rs | 3 +-- crates/fhir-validator/src/packages/manifest.rs | 5 +---- crates/fhir-validator/src/packages/materialize.rs | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/fhir-validator/src/packages/cache.rs b/crates/fhir-validator/src/packages/cache.rs index d2ea8d170..ea49a4d95 100644 --- a/crates/fhir-validator/src/packages/cache.rs +++ b/crates/fhir-validator/src/packages/cache.rs @@ -166,8 +166,7 @@ impl PackageCache { "directory {} has multiple package tarballs ({}); pass one explicitly \ (prefer package.tgz)", path.display(), - many - .iter() + many.iter() .filter_map(|p| p.file_name().and_then(|n| n.to_str())) .collect::>() .join(", ") diff --git a/crates/fhir-validator/src/packages/manifest.rs b/crates/fhir-validator/src/packages/manifest.rs index b7b1c638d..33453ffc8 100644 --- a/crates/fhir-validator/src/packages/manifest.rs +++ b/crates/fhir-validator/src/packages/manifest.rs @@ -68,10 +68,7 @@ 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() - )) + PackageError::Manifest(format!("failed to parse {}: {e}", path.display())) })?; if manifest.name.is_empty() || manifest.version.is_empty() { return Err(PackageError::Manifest(format!( diff --git a/crates/fhir-validator/src/packages/materialize.rs b/crates/fhir-validator/src/packages/materialize.rs index 8da827ff9..41e5772b6 100644 --- a/crates/fhir-validator/src/packages/materialize.rs +++ b/crates/fhir-validator/src/packages/materialize.rs @@ -54,9 +54,7 @@ pub fn materialize_package( } } Err(e) => { - report - .convert_errors - .push(format!("{}: {e}", sd_label(sd))); + report.convert_errors.push(format!("{}: {e}", sd_label(sd))); } } } From 484b0e4f6720bfc1c76477c8ec754ef6e69814bb Mon Sep 17 00:00:00 2001 From: Manjinder Sandhu Date: Sat, 1 Aug 2026 18:12:28 +0530 Subject: [PATCH 09/10] test(validator): cover enableWhen, answerOption, and answerValueSet paths Add unit tests for enableWhen gating of required items (including the warning on answered-but-disabled items and enableBehavior: any), answerOption membership, and answerValueSet membership via a stub terminology provider (skipped, not failed, when no provider is set). Co-authored-by: Cursor --- crates/fhir-validator/src/questionnaire.rs | 204 +++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/crates/fhir-validator/src/questionnaire.rs b/crates/fhir-validator/src/questionnaire.rs index 593783381..7cca318fa 100644 --- a/crates/fhir-validator/src/questionnaire.rs +++ b/crates/fhir-validator/src/questionnaire.rs @@ -349,8 +349,35 @@ fn answer_as_coded(answer: &Value) -> Option { #[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!({ @@ -415,4 +442,181 @@ mod tests { "{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:?}"); + } } From c071f2d12f2114154a94381944a6c2eeebe07e64 Mon Sep 17 00:00:00 2001 From: smunini Date: Tue, 4 Aug 2026 16:47:32 -0400 Subject: [PATCH 10/10] fix(validator): use FhirVersion::enabled_versions instead of a local copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enabled_fhir_versions` was a cfg-ladder that pushed one version per enabled feature. Under `--all-features` — which is what CI's clippy job builds — every cfg vanishes and the ladder reads as four unconditional pushes, tripping `clippy::vec_init_then_push` and failing the Linting job. helios-fhir already exposes exactly this list as `FhirVersion::enabled_versions()`, returning a `&'static [FhirVersion]` that `materialize_package_layers_by_version` takes directly, so the local copy goes away rather than gaining an `#[allow]`. Claude-Session: https://claude.ai/code/session_01E35FipGEpRDxvdZGLT5HcG --- crates/rest/src/validation.rs | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/crates/rest/src/validation.rs b/crates/rest/src/validation.rs index beb102893..116f29d7f 100644 --- a/crates/rest/src/validation.rs +++ b/crates/rest/src/validation.rs @@ -391,19 +391,6 @@ impl ValidationService { } } -fn enabled_fhir_versions() -> Vec { - let mut versions = Vec::new(); - #[cfg(feature = "R4")] - versions.push(FhirVersion::R4); - #[cfg(feature = "R4B")] - versions.push(FhirVersion::R4B); - #[cfg(feature = "R5")] - versions.push(FhirVersion::R5); - #[cfg(feature = "R6")] - versions.push(FhirVersion::R6); - versions -} - fn load_package_layers( config: &ValidationConfig, default_version: FhirVersion, @@ -440,9 +427,12 @@ fn load_package_layers( roots }; - let versions = enabled_fhir_versions(); + // 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) + 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 {