diff --git a/.claude/skills/run-hfs-server/SKILL.md b/.claude/skills/run-hfs-server/SKILL.md index 48247c96e..61df8d796 100644 --- a/.claude/skills/run-hfs-server/SKILL.md +++ b/.claude/skills/run-hfs-server/SKILL.md @@ -128,6 +128,28 @@ curl http://localhost:8080/clinic-a/Patient | `HFS_ENABLE_VERSIONING` | `true` | Enable ETag versioning | | `HFS_REQUIRE_IF_MATCH` | `false` | Require If-Match header for updates | +## Resource Validation + +FHIR Schema based validation (helios-fhir-validator). `$validate` is always +available (`POST /[type]/$validate`, `GET|POST /[type]/[id]/$validate`); +these settings gate the write path and tune the shared service. Stored +StructureDefinitions (per tenant) become validatable profiles on write. + +| Variable | Default | Description | +|---|---|---| +| `HFS_VALIDATION_MODE` | `off` | Write-path validation: `off`, `log`, or `enforce` (422 on invalid) | +| `HFS_VALIDATION_META_PROFILES` | `true` | Validate against `meta.profile` claims | +| `HFS_VALIDATION_UNKNOWN_PROFILE` | `warn` | Unresolvable profiles: `warn`, `error`, or `ignore` | +| `HFS_VALIDATION_CONSTRAINTS` | `true` | Evaluate FHIRPath invariants | +| `HFS_VALIDATION_SUPPRESS_CONSTRAINTS` | `dom-6` | Comma-separated constraint ids to skip | +| `HFS_VALIDATION_TERMINOLOGY` | `off` | Required-binding checks: `off` or `remote` (`ValueSet/$validate-code` against `HFS_TERMINOLOGY_SERVER`) | +| `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 | + +Note: tenant profile registries are in-memory and populated by +StructureDefinition writes since process start (no startup warm-load yet). + ## API Endpoints | Interaction | Method | URL | diff --git a/CLAUDE.md b/CLAUDE.md index 476493ccd..cd1ab53f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Workspace Structure -The project is a Rust workspace with 17 crates (16 default-members; `pysof` excluded from the default build): +The project is a Rust workspace with 19 crates (18 default-members; `pysof` excluded from the default build): | Crate | Description | |-------|-------------| @@ -15,6 +15,7 @@ The project is a Rust workspace with 17 crates (16 default-members; `pysof` excl | **`helios-fhir-macro`** | Procedural macros for FHIR functionality. | | **`helios-fhirpath`** | FHIRPath expression language — parser (chumsky), evaluator, CLI tool, and HTTP server. | | **`helios-fhirpath-support`** | Shared support utilities for FHIRPath. | +| **`helios-fhir-validator`** | FHIR resource validation — FHIR Schema based structural/profile engine, SD→schema converter, embedded core packs (R4–R6), deferred FHIRPath-constraint and terminology-binding effects. Configured via `HFS_VALIDATION_*`. | | **`helios-serde`** | JSON and XML serialization for FHIR resources (`xml` feature flag). | | **`helios-serde-support`** | Shared serde helpers. | | **`helios-rest`** | FHIR RESTful API layer (Axum) — handlers, middleware, extractors, multi-tenancy routing. | @@ -26,6 +27,7 @@ The project is a Rust workspace with 17 crates (16 default-members; `pysof` excl | **`helios-audit`** | Audit logging — FHIR AuditEvent with IHE BALP profiles; pluggable sinks (database, file, CloudWatch, S3). Configured via `HFS_AUDIT_*`. | | **`helios-subscriptions`** | FHIR topic-based Subscriptions engine — rest-hook, websocket, email, and messaging channels. Configured via `HFS_SUBSCRIPTION(S)_*`. | | **`helios-cds-hooks`** | CDS Hooks protocol types and async service trait (HL7 CDS Hooks v3.0.0-ballot). Standalone library. | +| **`helios-web`** | HTMX-first web UI foundation for HFS. | | **`pysof`** | Python bindings (PyO3/maturin) for SQL-on-FHIR. Excluded from default workspace build. | ### Binaries @@ -39,6 +41,7 @@ The project is a Rust workspace with 17 crates (16 default-members; `pysof` excl | `sof-server` | helios-sof | SQL-on-FHIR HTTP server | | `config-advisor` | helios-persistence | Storage configuration advisor | | `hts` | helios-hts | FHIR Terminology Server (HTS) | +| `validator-cli` | helios-fhir-validator | FHIR resource validator CLI (`cli` feature) | ### Key Design Patterns diff --git a/Cargo.lock b/Cargo.lock index a4b6c9f2c..ba977b468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3247,6 +3247,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "helios-fhir-validator" +version = "0.2.1" +dependencies = [ + "async-trait", + "clap", + "flate2", + "helios-fhir", + "helios-fhirpath", + "indexmap 2.14.0", + "regex", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "helios-fhirpath" version = "0.2.1" @@ -3454,6 +3470,7 @@ dependencies = [ "helios-audit", "helios-auth", "helios-fhir", + "helios-fhir-validator", "helios-observability", "helios-persistence", "helios-serde", diff --git a/Cargo.toml b/Cargo.toml index a93689b85..9fb58b3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ default-members = [ "crates/fhir", "crates/fhir-gen", "crates/fhir-macro", + "crates/fhir-validator", "crates/fhirpath", "crates/fhirpath-support", "crates/serde", @@ -39,4 +40,5 @@ rust-version = "1.90" serde = { version = "=1.0.224", features = ["derive"] } serde_json = "=1.0.144" helios-fhir = { path = "crates/fhir", version = "0.2.1", default-features = false } +helios-fhir-validator = { path = "crates/fhir-validator", version = "0.2.1", default-features = false } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/fhir-validator/Cargo.toml b/crates/fhir-validator/Cargo.toml new file mode 100644 index 000000000..8ea9cdb0d --- /dev/null +++ b/crates/fhir-validator/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "helios-fhir-validator" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +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" +keywords = ["helios-software", "hl7", "fhir", "validation", "fhir-schema"] + +[features] +default = ["R4"] +R4 = ["helios-fhir/R4", "helios-fhirpath?/R4"] +R4B = ["helios-fhir/R4B", "helios-fhirpath?/R4B"] +R5 = ["helios-fhir/R5", "helios-fhirpath?/R5"] +R6 = ["helios-fhir/R6", "helios-fhirpath?/R6"] +# FHIRPath-backed ConstraintEvaluator (pulls in helios-fhirpath). +fhirpath = ["dep:helios-fhirpath"] +# Enables the generate-schema-packs binary (dev-time; reads the FHIR spec +# bundles from crates/fhir-gen/resources and writes packs/). +gen-packs = [] +# Enables the validator-cli binary. +cli = ["dep:clap"] + +[dependencies] +helios-fhir = { path = "../fhir", version = "0.2.1", default-features = false } +# `rc` enables Deserialize/Serialize for Arc — element schemas are shared +# by reference across cooperative schema sets. +serde = { workspace = true, features = ["rc"] } +# `preserve_order` is load-bearing: the conformance contract requires errors +# to be emitted in document key order. +serde_json = { workspace = true, features = ["preserve_order"] } +indexmap = { version = "2", features = ["serde"] } +# Schema pack (de)compression. +flate2 = "1" +# Primitive value regexes (FHIR spec patterns carried in the packs). +regex = "1" +# Dyn-compatible async TerminologyProvider. +async-trait = "0.1" +clap = { version = "4", features = ["derive"], optional = true } +helios-fhirpath = { path = "../fhirpath", version = "0.2.1", optional = true, default-features = false } + +[dev-dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { version = "1", features = ["rt", "macros"] } + +[[bin]] +name = "generate-schema-packs" +path = "src/bin/generate_schema_packs.rs" +required-features = ["gen-packs"] + +[[bin]] +name = "validator-cli" +path = "src/bin/validator_cli.rs" +required-features = ["cli"] diff --git a/crates/fhir-validator/packs/fhir_schemas_r4.json.gz b/crates/fhir-validator/packs/fhir_schemas_r4.json.gz new file mode 100644 index 000000000..f538901d5 Binary files /dev/null and b/crates/fhir-validator/packs/fhir_schemas_r4.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz b/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz new file mode 100644 index 000000000..8c9df8c6e Binary files /dev/null and b/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r5.json.gz b/crates/fhir-validator/packs/fhir_schemas_r5.json.gz new file mode 100644 index 000000000..6f7684df0 Binary files /dev/null and b/crates/fhir-validator/packs/fhir_schemas_r5.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r6.json.gz b/crates/fhir-validator/packs/fhir_schemas_r6.json.gz new file mode 100644 index 000000000..a232036fd Binary files /dev/null and b/crates/fhir-validator/packs/fhir_schemas_r6.json.gz differ diff --git a/crates/fhir-validator/src/bin/generate_schema_packs.rs b/crates/fhir-validator/src/bin/generate_schema_packs.rs new file mode 100644 index 000000000..e5a90874e --- /dev/null +++ b/crates/fhir-validator/src/bin/generate_schema_packs.rs @@ -0,0 +1,141 @@ +//! Dev-time generator for the embedded core schema packs. +//! +//! Reads the FHIR spec bundles already vendored for code generation +//! (`crates/fhir-gen/resources/{R4,R4B,R5,R6}/profiles-{types,resources,others}.json`), +//! converts every StructureDefinition to FHIR Schema, and writes one sorted, +//! gzipped JSON array per version to `crates/fhir-validator/packs/`. +//! +//! The packs are **committed** (same workflow as the fhir-gen generated +//! models) and embedded via `include_bytes!` in `src/packs.rs`. Re-run after +//! spec updates: +//! +//! ```text +//! cargo run -p helios-fhir-validator --features gen-packs --bin generate-schema-packs +//! cargo run -p helios-fhir-validator --features gen-packs --bin generate-schema-packs -- R5 R6 +//! ``` +//! +//! Versions whose spec files are absent (R6 before fhir-gen's download has +//! run) are skipped with a warning. + +use flate2::write::GzEncoder; +use flate2::Compression; +use helios_fhir_validator::converter::convert; +use serde_json::Value; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const SOURCE_FILES: [&str; 3] = + ["profiles-types.json", "profiles-resources.json", "profiles-others.json"]; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let versions: Vec<&str> = if args.is_empty() { + vec!["R4", "R4B", "R5", "R6"] + } else { + args.iter().map(String::as_str).collect() + }; + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let resources_root = manifest_dir.join("../fhir-gen/resources"); + let packs_dir = manifest_dir.join("packs"); + fs::create_dir_all(&packs_dir).expect("create packs dir"); + + let mut failed = false; + for version in versions { + match generate_pack(&resources_root.join(version), &packs_dir, version) { + Ok(report) => println!("{report}"), + Err(e) => { + eprintln!("{version}: {e}"); + failed = true; + } + } + } + if failed { + std::process::exit(1); + } +} + +fn generate_pack(source_dir: &Path, packs_dir: &Path, version: &str) -> Result { + if !source_dir.exists() { + return Ok(format!( + "{version}: SKIPPED — spec directory {} not present (run a fhir-gen R6 build to download)", + source_dir.display() + )); + } + + let mut schemas: Vec = Vec::new(); + let mut sd_count = 0usize; + let mut warning_count = 0usize; + let mut failures: Vec = Vec::new(); + + for file in SOURCE_FILES { + let path = source_dir.join(file); + if !path.exists() { + eprintln!("{version}: {file} missing; continuing without it"); + continue; + } + let raw = fs::read_to_string(&path).map_err(|e| format!("read {file}: {e}"))?; + let bundle: Value = serde_json::from_str(&raw).map_err(|e| format!("parse {file}: {e}"))?; + let entries = bundle + .get("entry") + .and_then(Value::as_array) + .ok_or_else(|| format!("{file}: no Bundle.entry"))?; + + for entry in entries { + let Some(resource) = entry.get("resource") else { continue }; + if resource.get("resourceType").and_then(Value::as_str) != Some("StructureDefinition") + { + continue; + } + sd_count += 1; + let sd_url = resource.get("url").and_then(Value::as_str).unwrap_or(""); + match convert(resource) { + Ok(conversion) => { + warning_count += conversion.warnings.len(); + for w in &conversion.warnings { + eprintln!("{version}: [warn] {sd_url}: {w}"); + } + schemas.push( + serde_json::to_value(&conversion.schema).expect("schema serializes"), + ); + } + Err(e) => failures.push(format!("{sd_url}: {e}")), + } + } + } + + if !failures.is_empty() { + return Err(format!( + "{} of {} StructureDefinitions failed to convert:\n {}", + failures.len(), + sd_count, + failures.join("\n ") + )); + } + + // Diff-stable output: sort by url. + schemas.sort_by(|a, b| { + a.get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .cmp(b.get("url").and_then(Value::as_str).unwrap_or_default()) + }); + + let json = serde_json::to_vec(&schemas).expect("pack serializes"); + let out_path = packs_dir.join(format!("fhir_schemas_{}.json.gz", version.to_lowercase())); + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(&json).expect("gzip write"); + let compressed = encoder.finish().expect("gzip finish"); + fs::write(&out_path, &compressed).map_err(|e| format!("write pack: {e}"))?; + + Ok(format!( + "{version}: {} schemas from {} StructureDefinitions ({} converter warnings) → {} ({} KB raw, {} KB gz)", + schemas.len(), + sd_count, + warning_count, + out_path.display(), + json.len() / 1024, + compressed.len() / 1024 + )) +} diff --git a/crates/fhir-validator/src/bin/validator_cli.rs b/crates/fhir-validator/src/bin/validator_cli.rs new file mode 100644 index 000000000..46c6a7884 --- /dev/null +++ b/crates/fhir-validator/src/bin/validator_cli.rs @@ -0,0 +1,177 @@ +//! validator-cli — validate a FHIR resource from a file or stdin against the +//! embedded core schemas (and optional profiles), following the +//! fhirpath-cli / sof-cli pattern. +//! +//! ```text +//! validator-cli patient.json +//! cat patient.json | validator-cli --fhir-version R4 --output json +//! validator-cli patient.json --profile http://example.org/StructureDefinition/my-patient \ +//! --profile-file my-profile-sd.json +//! ``` +//! +//! Exit codes: 0 = valid (no error-severity issues), 1 = validation errors, +//! 2 = usage/input failure. + +use clap::Parser; +use helios_fhir::FhirVersion; +use helios_fhir_validator::converter::convert; +use helios_fhir_validator::packs::core_registry; +use helios_fhir_validator::{ + CompositeResolver, SchemaRegistry, Severity, UnknownProfilePolicy, ValidationOptions, + Validator, +}; +use std::fs; +use std::io::Read; +use std::process::ExitCode; +use std::sync::Arc; + +#[derive(Parser)] +#[command( + name = "validator-cli", + about = "Validate a FHIR resource against the core specification and optional profiles", + version +)] +struct Args { + /// Resource file to validate (JSON). Reads stdin when omitted. + file: Option, + + /// FHIR version to validate against. + #[arg(long, value_enum, default_value_t = FhirVersion::R4)] + fhir_version: FhirVersion, + + /// Additional profile canonical(s) to validate against (repeatable). + /// The profile must resolve — from --profile-file or the core pack. + #[arg(long = "profile")] + profiles: Vec, + + /// StructureDefinition file(s) to convert and make resolvable + /// (repeatable). Use together with --profile, or let meta.profile pick + /// them up. + #[arg(long = "profile-file")] + profile_files: Vec, + + /// Ignore the profiles the resource claims in meta.profile. + #[arg(long)] + no_meta_profiles: bool, + + /// Output format. + #[arg(long, value_enum, default_value_t = Output::Text)] + output: Output, +} + +#[derive(Clone, Copy, PartialEq, clap::ValueEnum)] +enum Output { + Text, + Json, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + let raw = match &args.file { + Some(path) => match fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("error: cannot read {path}: {e}"); + return ExitCode::from(2); + } + }, + None => { + let mut buf = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut buf) { + eprintln!("error: cannot read stdin: {e}"); + return ExitCode::from(2); + } + buf + } + }; + let resource: serde_json::Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + eprintln!("error: input is not valid JSON: {e}"); + return ExitCode::from(2); + } + }; + + // Core pack, optionally overlaid with converted profile files. + let core = core_registry(args.fhir_version); + let resolver: Arc = if args.profile_files.is_empty() + { + core + } else { + let mut overlay = SchemaRegistry::new(); + for path in &args.profile_files { + let sd: serde_json::Value = match fs::read_to_string(path) + .map_err(|e| e.to_string()) + .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) + { + Ok(v) => v, + Err(e) => { + eprintln!("error: cannot load profile {path}: {e}"); + return ExitCode::from(2); + } + }; + match convert(&sd) { + Ok(conversion) => { + for w in &conversion.warnings { + eprintln!("warning: {path}: {w}"); + } + if !overlay.insert(conversion.schema) { + eprintln!("error: profile {path} has neither url nor name"); + return ExitCode::from(2); + } + } + Err(e) => { + eprintln!("error: profile {path} failed to convert: {e}"); + return ExitCode::from(2); + } + } + } + Arc::new(CompositeResolver::new(vec![Arc::new(overlay), core])) + }; + + let validator = Validator::new(resolver); + let opts = ValidationOptions { + profiles: args.profiles.clone(), + use_meta_profiles: !args.no_meta_profiles, + unknown_profile: UnknownProfilePolicy::Warn, + }; + let outcome = validator.validate_sync(&resource, &opts); + + let error_count = + outcome.errors.iter().filter(|e| e.severity == Severity::Error).count(); + + match args.output { + Output::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "valid": error_count == 0, + "errors": outcome.errors, + })) + .expect("report serializes") + ); + } + Output::Text => { + if outcome.errors.is_empty() { + println!("valid: no issues"); + } else { + for e in &outcome.errors { + let sev = if e.severity == Severity::Error { "error" } else { "warning" }; + let kind = serde_json::to_value(e.kind) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default(); + println!("{sev} [{kind}] {}: {}", e.path, e.message); + } + println!( + "{} issue(s), {} error(s)", + outcome.errors.len(), + error_count + ); + } + } + } + + if error_count > 0 { ExitCode::from(1) } else { ExitCode::SUCCESS } +} diff --git a/crates/fhir-validator/src/converter/mod.rs b/crates/fhir-validator/src/converter/mod.rs new file mode 100644 index 000000000..7190afcb6 --- /dev/null +++ b/crates/fhir-validator/src/converter/mod.rs @@ -0,0 +1,300 @@ +//! StructureDefinition → FhirSchema conversion (fhir2schema). +//! +//! Consumes StructureDefinitions as **raw JSON** — deliberately not the +//! fhir-gen bootstrap model (whose build.rs performs the R6 download) and not +//! the typed models (uploaded profiles arrive as `Value` from the REST +//! extractor). Works from `snapshot.element` when present, otherwise +//! `differential.element`: because FHIR Schema is differential-by-design and +//! validation layers schemas cooperatively, **no snapshot generation is ever +//! needed** — sparse differential paths simply produce empty intermediate +//! nodes that the base layers fill in at validation time. +//! +//! Mapping summary (pinned by `tests/converter_tests.rs` goldens): +//! +//! | ElementDefinition | FhirSchema | +//! |---|---| +//! | `max == "0"` | parent `excluded` (element omitted) | +//! | `min >= 1` | parent `required` (choice base name for `foo[x]`) | +//! | `base.max` (else `max`) `*`/`>1` | `array: true` + numeric `min`/`max` | +//! | multi-type / `foo[x]` | `choices` declarer + one `choiceOf` branch per type | +//! | `contentReference: "#A.b"` | `elementReference: ["A", "elements", "b"]` | +//! | `slicing.discriminator` | `slicing.slices[].match` (pattern) — see `slicing` | +//! | extension slice + type profile | parent `extensions` sugar | +//! | `binding` | `{valueSet, strength}` carried for all strengths | +//! | `constraint[]` | `constraints` map (`ele-1`/`ext-1` dropped off non-root elements — they are enforced once via the `Element`/`Extension` type schemas) | +//! | `fixed[x]` / `pattern[x]` | `fixed` / `pattern` | +//! | `type[].targetProfile` | `refers` | +//! | primitive `.value` regex extension | schema `regex` | +//! +//! Additionally, `resourceType: {type: "code"}` is injected into resource +//! schemas with `derivation != constraint` (StructureDefinitions never +//! declare `resourceType`; profiles inherit the injected element through +//! their base chain). + +mod primitives; +mod slicing; +mod tree; + +use crate::schema::FhirSchema; +use serde::Deserialize; +use serde_json::Value; +use std::fmt; + +/// A successful conversion: the schema plus non-fatal warnings (unmappable +/// discriminators, dropped slice minimums, missing primitive regexes, ...). +#[derive(Debug)] +pub struct Conversion { + pub schema: FhirSchema, + pub warnings: Vec, +} + +/// A conversion failure. +#[derive(Debug)] +pub enum ConvertError { + /// The input is not a StructureDefinition. + NotAStructureDefinition, + /// The StructureDefinition is missing a required part. + Missing(&'static str), + /// The element list is malformed. + Malformed(String), +} + +impl fmt::Display for ConvertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConvertError::NotAStructureDefinition => { + write!(f, "input is not a StructureDefinition") + } + ConvertError::Missing(what) => write!(f, "StructureDefinition is missing {what}"), + ConvertError::Malformed(detail) => write!(f, "malformed StructureDefinition: {detail}"), + } + } +} + +impl std::error::Error for ConvertError {} + +// --------------------------------------------------------------------- +// Lightweight ElementDefinition model (tolerant serde over raw JSON). +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct Ed { + pub id: Option, + pub path: String, + #[serde(rename = "sliceName")] + pub slice_name: Option, + pub min: Option, + pub max: Option, + pub base: Option, + #[serde(rename = "type", default)] + pub types: Vec, + #[serde(rename = "contentReference")] + pub content_reference: Option, + pub slicing: Option, + pub binding: Option, + #[serde(default)] + pub constraint: Vec, + /// Everything else — scanned for `fixed[x]` / `pattern[x]`. + #[serde(flatten)] + pub rest: serde_json::Map, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdBase { + pub max: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdType { + pub code: String, + #[serde(default)] + pub profile: Vec, + #[serde(rename = "targetProfile", default)] + pub target_profile: Vec, + #[serde(default)] + pub extension: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdTypeExtension { + pub url: String, + #[serde(rename = "valueString")] + pub value_string: Option, + #[serde(rename = "valueUrl")] + pub value_url: Option, + #[serde(rename = "valueUri")] + pub value_uri: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdSlicing { + #[serde(default)] + pub discriminator: Vec, + pub rules: Option, + pub ordered: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct EdDiscriminator { + #[serde(rename = "type")] + pub type_: String, + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdBinding { + pub strength: Option, + #[serde(rename = "valueSet")] + pub value_set: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EdConstraint { + pub key: String, + pub severity: Option, + pub human: Option, + pub expression: Option, +} + +/// The FHIR type-code extensions carried on `type[].extension`. +const REGEX_EXTENSION: &str = "http://hl7.org/fhir/StructureDefinition/regex"; +const FHIR_TYPE_EXTENSION: &str = + "http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type"; + +impl EdType { + /// The effective type code: FHIRPath system types + /// (`http://hl7.org/fhirpath/System.String`, ...) resolve through the + /// `structuredefinition-fhir-type` extension, falling back to the + /// lower-cased suffix. + pub(crate) fn effective_code(&self) -> String { + if let Some(suffix) = self.code.strip_prefix("http://hl7.org/fhirpath/System.") { + for ext in &self.extension { + if ext.url == FHIR_TYPE_EXTENSION + && let Some(v) = ext + .value_url + .as_deref() + .or(ext.value_uri.as_deref()) + .or(ext.value_string.as_deref()) + { + return v.to_string(); + } + } + let mut chars = suffix.chars(); + return match chars.next() { + Some(first) => first.to_lowercase().collect::() + chars.as_str(), + None => suffix.to_string(), + }; + } + self.code.clone() + } + + /// The regex constraint carried on primitive value elements. + pub(crate) fn regex(&self) -> Option<&str> { + self.extension + .iter() + .find(|e| e.url == REGEX_EXTENSION) + .and_then(|e| e.value_string.as_deref()) + } +} + +/// Convert a StructureDefinition (raw JSON, snapshot or differential) to a +/// FHIR Schema. +pub fn convert(sd: &Value) -> Result { + if sd.get("resourceType").and_then(Value::as_str) != Some("StructureDefinition") { + return Err(ConvertError::NotAStructureDefinition); + } + + let get_str = |key: &str| sd.get(key).and_then(Value::as_str).map(str::to_string); + let url = get_str("url").ok_or(ConvertError::Missing("url"))?; + let name = get_str("name"); + let sd_type = get_str("type").ok_or(ConvertError::Missing("type"))?; + let kind_raw = get_str("kind").ok_or(ConvertError::Missing("kind"))?; + let derivation = get_str("derivation"); + let base = get_str("baseDefinition"); + + let element_values = sd + .get("snapshot") + .and_then(|s| s.get("element")) + .or_else(|| sd.get("differential").and_then(|d| d.get("element"))) + .and_then(Value::as_array) + .ok_or(ConvertError::Missing("snapshot.element or differential.element"))?; + + let elements: Vec = element_values + .iter() + .map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| ConvertError::Malformed(format!("element: {e}"))) + }) + .collect::>()?; + + let mut warnings: Vec = Vec::new(); + + // Primitive types produce a leaf schema with a value regex. + if kind_raw == "primitive-type" { + let schema = primitives::convert_primitive( + url, name, &sd_type, derivation, &elements, &mut warnings, + ); + return Ok(Conversion { schema, warnings }); + } + + // `Extension`-constraining SDs get the dedicated kind. + let kind = if sd_type == "Extension" && derivation.as_deref() == Some("constraint") { + "extension".to_string() + } else { + kind_raw.clone() + }; + + // Build the nested element tree by navigating each ED's id segments. + let mut root = tree::Node::new(sd_type.clone()); + for ed in &elements { + // Navigation is id-based; hand-authored differentials may omit ids, + // in which case the slice position is reconstructed from sliceName. + let synthesized; + let id = match (&ed.id, &ed.slice_name) { + (Some(id), _) => id.as_str(), + (None, Some(slice)) => { + synthesized = format!("{}:{slice}", ed.path); + &synthesized + } + (None, None) => &ed.path, + }; + let segments = tree::parse_segments(id) + .map_err(|e| ConvertError::Malformed(format!("element id '{id}': {e}")))?; + if segments.is_empty() { + // Root element: constraints attach at schema level (all kept — + // dom-* live here). + tree::apply_constraints(&mut root.schema, &ed.constraint, true); + continue; + } + tree::apply(&mut root, &segments, ed, &mut warnings); + } + + let mut schema = tree::finalize(root, &mut warnings).unwrap_or_default(); + + // Header fields. + schema.url = Some(url); + schema.name = name; + schema.base = base; + schema.kind = Some(kind); + schema.derivation = derivation.clone(); + schema.type_ = Some(sd_type); + + // StructureDefinitions never declare `resourceType`; inject it for + // resource specializations (profiles inherit it via their base chain). + if kind_raw == "resource" && derivation.as_deref() != Some("constraint") { + let mut elements = indexmap::IndexMap::new(); + elements.insert( + "resourceType".to_string(), + std::sync::Arc::new(FhirSchema { + type_: Some("code".to_string()), + ..Default::default() + }), + ); + if let Some(existing) = schema.elements.take() { + elements.extend(existing); + } + schema.elements = Some(elements); + } + + Ok(Conversion { schema, warnings }) +} diff --git a/crates/fhir-validator/src/converter/primitives.rs b/crates/fhir-validator/src/converter/primitives.rs new file mode 100644 index 000000000..9fd888663 --- /dev/null +++ b/crates/fhir-validator/src/converter/primitives.rs @@ -0,0 +1,45 @@ +//! Primitive-type StructureDefinitions → leaf schemas. +//! +//! A primitive SD becomes `{kind: "primitive-type"}` plus the value regex +//! extracted from its `.value` element +//! (`type[0].extension[url=.../regex].valueString`). No `base` is emitted: +//! pulling `Element` into every primitive's cooperative set would be wasted +//! work — the `_field` sidecar machinery owns the Element part instead. +//! +//! There is deliberately no hardcoded fallback regex table: the spec files +//! carry the regexes, and a wrong guess is worse than the JSON type-class +//! check the engine applies from the primitive's name anyway. A missing +//! regex just produces a warning. + +use super::Ed; +use crate::schema::FhirSchema; + +pub(super) fn convert_primitive( + url: String, + name: Option, + sd_type: &str, + derivation: Option, + elements: &[Ed], + warnings: &mut Vec, +) -> FhirSchema { + let value_path = format!("{sd_type}.value"); + let regex = elements + .iter() + .find(|ed| ed.path == value_path) + .and_then(|ed| ed.types.first()) + .and_then(|t| t.regex()) + .map(str::to_string); + if regex.is_none() { + warnings.push(format!("primitive '{sd_type}': no value regex found")); + } + + FhirSchema { + url: Some(url), + name, + kind: Some(crate::schema::kind::PRIMITIVE_TYPE.to_string()), + derivation, + type_: Some(sd_type.to_string()), + regex, + ..Default::default() + } +} diff --git a/crates/fhir-validator/src/converter/slicing.rs b/crates/fhir-validator/src/converter/slicing.rs new file mode 100644 index 000000000..d549c3a8f --- /dev/null +++ b/crates/fhir-validator/src/converter/slicing.rs @@ -0,0 +1,128 @@ +//! Discriminator → slice-match translation. +//! +//! 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. + +use super::tree::{finalize, SliceNode}; +use super::EdDiscriminator; +use crate::schema::{Match, Slice, Slicing}; +use indexmap::IndexMap; +use serde_json::{Map, Value}; +use std::sync::Arc; + +pub(super) fn build_slicing( + slices: IndexMap, + discriminators: &[EdDiscriminator], + rules: Option, + ordered: Option, + warnings: &mut Vec, +) -> Option { + let mut out: IndexMap = IndexMap::new(); + for (name, slice_node) in slices { + let SliceNode { node, min, max, extension_profile: _ } = slice_node; + + let match_ = build_match(&node, discriminators); + if match_.is_none() { + warnings.push(format!( + "slice '{name}': discriminator(s) {:?} not translatable to a match; \ + slice kept without match or min", + discriminators.iter().map(|d| format!("{}@{}", d.type_, d.path)).collect::>() + )); + } + + let schema = finalize(node, warnings); + 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 }, + max, + match_, + order: None, + reslice: None, + slice_is_constraining: None, + schema: schema.map(Arc::new), + }, + ); + } + + if out.is_empty() { + return None; + } + Some(Slicing { slices: out, rules, ordered }) +} + +/// Build a pattern match from `value`/`pattern` discriminators, reading the +/// constant at each discriminator path out of the slice subtree. +fn build_match(node: &super::tree::Node, discriminators: &[EdDiscriminator]) -> Option { + if discriminators.is_empty() { + return None; + } + + 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; + } + 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 { + return None; // $this plus siblings — ambiguous + } + this_constant = Some(constant); + } else { + insert_nested(&mut pattern, &disc.path, constant); + } + } + + let value = match this_constant { + Some(v) => v, + None if !pattern.is_empty() => Value::Object(pattern), + None => return None, + }; + Some(Match { type_: Some("pattern".to_string()), value: Some(value), resolve_ref: 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 = if path == "$this" { + node + } else { + let mut current = node; + for segment in path.split('.') { + current = current.children.get(segment)?; + } + current + }; + target.schema.fixed.clone().or_else(|| target.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(); + let mut current = map; + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + current.insert(segment.to_string(), value); + return; + } + current = current + .entry(segment.to_string()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .expect("nested pattern segment is an object"); + } +} diff --git a/crates/fhir-validator/src/converter/tree.rs b/crates/fhir-validator/src/converter/tree.rs new file mode 100644 index 000000000..47d400d1d --- /dev/null +++ b/crates/fhir-validator/src/converter/tree.rs @@ -0,0 +1,426 @@ +//! ElementDefinition-id → nested tree reconstruction. +//! +//! Each ElementDefinition is applied by navigating its `id` absolutely from +//! the root, segment by segment (`name` or `name:sliceName`), creating empty +//! intermediate nodes as needed — which is what makes sparse differentials +//! work without snapshot generation. Choice (`foo[x]`) expansion also lives +//! here: the `[x]` element becomes a `choices` declarer plus one `choiceOf` +//! branch element per type. + +use super::{Ed, EdConstraint, EdDiscriminator}; +use crate::schema::{Binding, Constraint, FhirSchema}; +use indexmap::IndexMap; +use std::sync::Arc; + +/// One `id` step: an element name plus an optional slice name. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Segment { + pub name: String, + pub slice: Option, +} + +/// Parse an ElementDefinition id into segments, dropping the root segment. +/// `"Patient.identifier:mrn.system"` → `[identifier:mrn, system]`. +pub(super) fn parse_segments(id: &str) -> Result, String> { + let mut parts = id.split('.'); + let root = parts.next().ok_or("empty id")?; + if root.is_empty() { + return Err("empty root segment".to_string()); + } + let mut segments = Vec::new(); + for part in parts { + if part.is_empty() { + return Err("empty segment".to_string()); + } + match part.split_once(':') { + Some((name, slice)) if !name.is_empty() && !slice.is_empty() => { + segments.push(Segment { name: name.to_string(), slice: Some(slice.to_string()) }); + } + Some(_) => return Err(format!("malformed slice segment '{part}'")), + None => segments.push(Segment { name: part.to_string(), slice: None }), + } + } + Ok(segments) +} + +/// A node of the element tree under construction. +#[derive(Debug, Default)] +pub(super) struct Node { + /// The element name this node sits under (used for extension-sugar + /// detection on `extension`/`modifierExtension`). + pub element_name: String, + /// Accumulated element-level keywords. + pub schema: FhirSchema, + pub children: IndexMap, + pub slices: IndexMap, + /// Slicing declaration from the sliced element's own ED. + pub discriminators: Vec, + pub slicing_rules: Option, + pub slicing_ordered: Option, + /// `max: "0"` — the element is prohibited; recorded on the parent's + /// `excluded` and the subtree is dropped. + pub dead: bool, +} + +impl Node { + pub(super) fn new(element_name: String) -> Self { + Self { element_name, ..Default::default() } + } +} + +#[derive(Debug, Default)] +pub(super) struct SliceNode { + pub node: Node, + pub min: Option, + pub max: Option, + /// `type[0] == Extension` with a profile: compiles to the parent-level + /// `extensions` sugar instead of raw slicing. + pub extension_profile: Option, +} + +/// Apply one ElementDefinition at its navigated position. +pub(super) fn apply(root: &mut Node, segments: &[Segment], ed: &Ed, warnings: &mut Vec) { + // Navigate to the parent of the final segment. + let mut node: &mut Node = root; + for seg in &segments[..segments.len() - 1] { + if node.dead { + return; + } + node = descend(node, seg); + } + if node.dead { + return; + } + let last = &segments[segments.len() - 1]; + + // Explicit choice-branch constraint: `value[x]:valueQuantity`. + if let (true, Some(slice)) = (last.name.ends_with("[x]"), &last.slice) { + let branch = node + .children + .entry(slice.clone()) + .or_insert_with(|| Node::new(slice.clone())); + apply_element_content(branch, ed, warnings); + return; + } + + // Slice definition: `identifier:mrn`. + 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(); + slice.min = ed.min; + slice.max = parse_numeric_max(ed.max.as_deref()); + if matches!(element.element_name.as_str(), "extension" | "modifierExtension") + && let Some(first) = ed.types.first() + && first.code == "Extension" + && let Some(profile) = first.profile.first() + { + slice.extension_profile = Some(profile.clone()); + } + // Value-level keywords on the slice ED itself apply to the slice + // schema (shape/cardinality were consumed above as slice bounds). + apply_value_keywords(&mut slice.node.schema, ed, warnings); + return; + } + + // Choice element: `deceased[x]` — declarer + one branch per type. + if let Some(base_name) = last.name.strip_suffix("[x]") { + apply_choice(node, base_name, ed, warnings); + return; + } + + // Plain element. + let name = last.name.clone(); + if ed.max.as_deref() == Some("0") { + push_unique(&mut node.schema.excluded, &name); + let element = + node.children.entry(name.clone()).or_insert_with(|| Node::new(name.clone())); + element.dead = true; + return; + } + if ed.min.unwrap_or(0) >= 1 { + push_unique(&mut node.schema.required, &name); + } + let element = node.children.entry(name.clone()).or_insert_with(|| Node::new(name)); + if let Some(slicing) = &ed.slicing { + element.discriminators = slicing.discriminator.clone(); + element.slicing_rules = slicing.rules.clone(); + element.slicing_ordered = slicing.ordered; + } + apply_shape(element, ed); + apply_element_content(element, ed, warnings); +} + +fn descend<'a>(node: &'a mut Node, seg: &Segment) -> &'a mut Node { + let child = node + .children + .entry(seg.name.clone()) + .or_insert_with(|| Node::new(seg.name.clone())); + match &seg.slice { + Some(slice) => &mut child.slices.entry(slice.clone()).or_default().node, + None => child, + } +} + +/// Array-ness comes from `base.max` when present (profiles constrain the +/// count, not the shape), else the ED's own `max`/`min`. +fn apply_shape(element: &mut Node, ed: &Ed) { + let shape_max = ed.base.as_ref().and_then(|b| b.max.as_deref()).or(ed.max.as_deref()); + let is_array = match shape_max { + Some("*") => true, + Some(n) => n.parse::().map(|n| n > 1).unwrap_or(false), + None => ed.min.unwrap_or(0) > 1, + }; + if is_array { + element.schema.array = Some(true); + if let Some(min) = ed.min + && min > 0 + { + element.schema.min = Some(min); + } + if let Some(max) = parse_numeric_max(ed.max.as_deref()) { + element.schema.max = Some(max); + } + } +} + +/// Type reference, contentReference, and the value-level keywords. +fn apply_element_content(element: &mut Node, ed: &Ed, warnings: &mut Vec) { + match ed.types.len() { + 0 => { + if let Some(reference) = &ed.content_reference { + match parse_content_reference(reference) { + Some(segments) => element.schema.element_reference = Some(segments), + None => warnings + .push(format!("{}: unparseable contentReference '{reference}'", ed.path)), + } + } + } + 1 => { + let t = &ed.types[0]; + element.schema.type_ = Some(t.effective_code()); + if !t.target_profile.is_empty() { + element.schema.refers = Some(t.target_profile.clone()); + } + } + _ => { + // Multiple types without `[x]` should not occur; take the first. + warnings.push(format!("{}: multiple types without [x]; using the first", ed.path)); + element.schema.type_ = Some(ed.types[0].effective_code()); + } + } + 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). +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 + { + schema.binding = Some(Binding { + value_set: value_set.clone(), + strength: binding.strength.clone(), + }); + } + apply_constraints(schema, &ed.constraint, false); + for (key, value) in &ed.rest { + if let Some(rest) = key.strip_prefix("fixed") + && !rest.is_empty() + && !key.starts_with('_') + { + schema.fixed = Some(value.clone()); + } else if let Some(rest) = key.strip_prefix("pattern") + && !rest.is_empty() + && !key.starts_with('_') + { + schema.pattern = Some(value.clone()); + } + } +} + +/// Carry FHIRPath constraints. On non-root elements the ubiquitous inherited +/// invariants (`ele-1` on every element, `ext-1` on every extension) are +/// dropped — they are enforced once via the `Element`/`Extension` type +/// schemas that join every relevant cooperative set, and carrying them +/// per-element would bloat packs and the deferred-effects list enormously. +pub(super) fn apply_constraints(schema: &mut FhirSchema, constraints: &[EdConstraint], root: bool) { + for c in constraints { + if !root && matches!(c.key.as_str(), "ele-1" | "ext-1") { + continue; + } + let Some(expression) = &c.expression else { + continue; // xpath-only constraint + }; + schema + .constraints + .get_or_insert_with(IndexMap::new) + .entry(c.key.clone()) + .or_insert_with(|| Constraint { + expression: expression.clone(), + severity: c.severity.clone(), + human: c.human.clone(), + }); + } +} + +/// Expand `foo[x]`: parent gets a `choices` declarer plus one branch element +/// per type, each carrying the value-level keywords of the `[x]` ED. +fn apply_choice(parent: &mut Node, base_name: &str, ed: &Ed, warnings: &mut Vec) { + if ed.max.as_deref() == Some("0") { + push_unique(&mut parent.schema.excluded, base_name); + return; + } + if ed.min.unwrap_or(0) >= 1 { + push_unique(&mut parent.schema.required, base_name); + } + + let mut branch_names = Vec::new(); + for t in &ed.types { + let code = t.effective_code(); + let branch_name = format!("{base_name}{}", capitalize(&code)); + branch_names.push(branch_name.clone()); + + let branch = parent + .children + .entry(branch_name.clone()) + .or_insert_with(|| Node::new(branch_name)); + branch.schema.type_ = Some(code); + branch.schema.choice_of = Some(base_name.to_string()); + if !t.target_profile.is_empty() { + branch.schema.refers = Some(t.target_profile.clone()); + } + apply_value_keywords(&mut branch.schema, ed, warnings); + } + + let declarer = parent + .children + .entry(base_name.to_string()) + .or_insert_with(|| Node::new(base_name.to_string())); + match &mut declarer.schema.choices { + Some(existing) => { + for name in branch_names { + if !existing.contains(&name) { + existing.push(name); + } + } + } + None => declarer.schema.choices = Some(branch_names), + } +} + +/// Recursively turn a tree node into a schema, pruning empty nodes. +pub(super) fn finalize(node: Node, warnings: &mut Vec) -> Option { + let Node { + element_name: _, + mut schema, + children, + slices, + discriminators, + slicing_rules, + slicing_ordered, + dead, + } = node; + + if dead { + return None; + } + + // Slices on this node (non-sugar) become a slicing definition. + if !slices.is_empty() { + let sugar: Vec<&String> = + slices.iter().filter(|(_, s)| s.extension_profile.is_some()).map(|(n, _)| n).collect(); + debug_assert!(sugar.is_empty(), "extension-sugar slices are lifted by the parent"); + let slicing = + super::slicing::build_slicing(slices, &discriminators, slicing_rules, slicing_ordered, warnings); + if let Some(slicing) = slicing { + schema.slicing = Some(slicing); + } + } + + // Children: lift extension-sugar slices into our `extensions` map, then + // finalize the rest into `elements`. + let mut elements: IndexMap> = IndexMap::new(); + for (name, mut child) in children { + // Extension sugar: slices on an extension child with a type profile + // become entries in *this* schema's `extensions` map. + if matches!(child.element_name.as_str(), "extension" | "modifierExtension") { + let mut remaining: IndexMap = IndexMap::new(); + for (slice_name, slice) in std::mem::take(&mut child.slices) { + match &slice.extension_profile { + Some(profile) => { + let mut entry = finalize(slice.node, warnings).unwrap_or_default(); + entry.url = Some(profile.clone()); + entry.min = slice.min; + entry.max = slice.max; + schema + .extensions + .get_or_insert_with(IndexMap::new) + .insert(slice_name, Arc::new(entry)); + } + None => { + remaining.insert(slice_name, slice); + } + } + } + child.slices = remaining; + } + if let Some(child_schema) = finalize(child, warnings) { + elements.insert(name, Arc::new(child_schema)); + } + } + if !elements.is_empty() { + schema.elements = Some(elements); + } + + if schema == FhirSchema::default() { + None + } else { + Some(schema) + } +} + +fn parse_numeric_max(max: Option<&str>) -> Option { + match max { + Some("*") | None => None, + Some(n) => n.parse().ok(), + } +} + +fn parse_content_reference(reference: &str) -> Option> { + let path = reference.strip_prefix('#')?; + let mut parts = path.split('.'); + let root = parts.next()?; + if root.is_empty() { + return None; + } + let mut segments = vec![root.to_string()]; + for part in parts { + if part.is_empty() { + return None; + } + segments.push("elements".to_string()); + segments.push(part.to_string()); + } + if segments.len() == 1 { + return None; // reference to a whole root — not an element reference + } + Some(segments) +} + +fn push_unique(list: &mut Option>, value: &str) { + let list = list.get_or_insert_with(Vec::new); + if !list.iter().any(|v| v == value) { + list.push(value.to_string()); + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} diff --git a/crates/fhir-validator/src/effects.rs b/crates/fhir-validator/src/effects.rs new file mode 100644 index 000000000..bc0721615 --- /dev/null +++ b/crates/fhir-validator/src/effects.rs @@ -0,0 +1,306 @@ +//! Deferred effects: validations collected during the pure synchronous walk +//! and executed afterwards. +//! +//! The structural walk ([`crate::engine`]) never performs I/O and never +//! evaluates FHIRPath. When it encounters a `constraints` or `binding` +//! keyword it records a [`Deferred`] entry; [`execute`] runs them through +//! pluggable handlers — a synchronous [`ConstraintEvaluator`] (the +//! FHIRPath-backed implementation lives behind the `fhirpath` feature) and +//! an async [`TerminologyProvider`] (HTTP `$validate-code`, in-process hts, +//! or a test stub). +//! +//! Execution semantics: +//! - constraints: deduplicated by `(path, id)` across cooperative layers; +//! `severity: error` failures emit error issues, `warning` failures emit +//! warning issues, `guideline` entries are skipped, ids on the suppress +//! list are skipped, and evaluation problems surface as warning issues +//! (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 +//! (`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. +//! +//! Issue order: all constraint issues (deferred order), then all binding +//! issues (deferred order) — always after the structural errors. + +use crate::engine::{ErrorKind, Severity, ValidationError}; +use crate::schema::Binding; +use async_trait::async_trait; +use helios_fhir::FhirVersion; +use serde_json::Value; +use std::collections::HashSet; + +// Message templates live with the rest of the contract in engine::errors; +// re-exported crate-internally via these thin wrappers. +use crate::engine::messages; + +/// A validation obligation collected during the sync walk. +#[derive(Debug, Clone, PartialEq)] +pub enum Deferred { + /// A FHIRPath invariant to evaluate with the node at `path` as focus. + Constraint { + /// Dotted conformance path of the node the constraint attaches to. + path: String, + /// Constraint id (e.g. `pat-1`). + id: String, + /// FHIRPath expression. + expression: String, + /// Human-readable description (used in the emitted message). + human: Option, + /// `error` | `warning` | `guideline`. + severity: Option, + }, + /// A terminology binding to check (filtered to `required` strength at + /// execution time). + Binding { + /// Dotted conformance path of the coded node. + path: String, + /// The binding (ValueSet canonical + strength). + binding: Binding, + /// The coded value as found in the data. + value: Value, + /// The element's declared type, when the schema stated one. + type_hint: Option, + }, +} + +/// A coded value in one of FHIR's three coded shapes. +#[derive(Debug, Clone, PartialEq)] +pub enum CodedValue { + /// A bare `code` string (system to be inferred from the ValueSet). + Code(String), + /// A Coding object. + Coding(Value), + /// A CodeableConcept object. + CodeableConcept(Value), +} + +/// Checks coded-value membership in a ValueSet (`$validate-code`). +#[async_trait] +pub trait TerminologyProvider: Send + Sync { + /// `Ok(true)` = the coded value is in the ValueSet. + async fn validate_code( + &self, + value_set: &str, + coded: &CodedValue, + ) -> Result; +} + +/// A terminology-service failure (network, unknown ValueSet, ...). +#[derive(Debug, Clone)] +pub struct TerminologyError(pub String); + +impl std::fmt::Display for TerminologyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for TerminologyError {} + +/// The outcome of evaluating one constraint. +#[derive(Debug, Clone, PartialEq)] +pub enum ConstraintOutcome { + Passed, + Failed, + /// The expression could not be evaluated (parse error, non-boolean + /// result, typed-model mismatch); surfaced as a warning issue. + NotEvaluable(String), +} + +/// Evaluates FHIRPath invariants against a resource. Implementations parse +/// the resource once per call and evaluate every constraint against it. +pub trait ConstraintEvaluator: Send + Sync { + /// Outcomes aligned index-for-index with `constraints`. + fn evaluate_all( + &self, + resource: &Value, + version: FhirVersion, + constraints: &[DeferredConstraint<'_>], + ) -> Vec; +} + +/// A borrowed view of one deferred constraint, handed to evaluators. +#[derive(Debug, Clone, Copy)] +pub struct DeferredConstraint<'a> { + /// Dotted conformance path of the node the constraint attaches to + /// (rooted at the resource type, numeric indices). + pub path: &'a str, + pub id: &'a str, + pub expression: &'a str, +} + +/// The handlers for one validation run. +#[derive(Default)] +pub struct EffectHandlers<'a> { + pub terminology: Option<&'a dyn TerminologyProvider>, + pub constraints: Option<&'a dyn ConstraintEvaluator>, + /// Constraint ids to skip entirely (e.g. `dom-6`). + pub suppress_constraints: &'a [String], + /// Treat terminology-service failures as errors instead of warnings. + pub terminology_fail_closed: bool, +} + +/// Execute the deferred obligations, appending issues to `errors`. +pub async fn execute( + resource: &Value, + version: FhirVersion, + deferred: &[Deferred], + handlers: &EffectHandlers<'_>, + errors: &mut Vec, +) { + execute_constraints(resource, version, deferred, handlers, errors); + execute_bindings(deferred, handlers, errors).await; +} + +fn execute_constraints( + resource: &Value, + version: FhirVersion, + deferred: &[Deferred], + handlers: &EffectHandlers<'_>, + errors: &mut Vec, +) { + let Some(evaluator) = handlers.constraints else { + return; + }; + + // Select, filter, and dedup — preserving deferred order. + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut selected: Vec<(&str, &str, &str, Option<&str>, Severity)> = Vec::new(); + for d in deferred { + let Deferred::Constraint { path, id, expression, human, severity } = d else { + continue; + }; + let issue_severity = match severity.as_deref() { + None | Some("error") => Severity::Error, + Some("warning") => Severity::Warning, + _ => continue, // guideline and unknown severities: not evaluated + }; + if handlers.suppress_constraints.iter().any(|s| s == id) { + continue; + } + if !seen.insert((path.clone(), id.clone())) { + continue; // same constraint from several cooperative layers + } + selected.push((path, id, expression, human.as_deref(), issue_severity)); + } + if selected.is_empty() { + return; + } + + let refs: Vec> = selected + .iter() + .map(|(path, id, expression, _, _)| DeferredConstraint { path, id, expression }) + .collect(); + let outcomes = evaluator.evaluate_all(resource, version, &refs); + debug_assert_eq!(outcomes.len(), refs.len(), "evaluator must align outcomes"); + + for ((path, id, _, human, severity), outcome) in selected.iter().zip(outcomes) { + match outcome { + ConstraintOutcome::Passed => {} + ConstraintOutcome::Failed => { + errors.push( + ValidationError::new( + ErrorKind::FhirpathConstraint, + path.to_string(), + messages::fhirpath_constraint(id, *human), + ) + .with_severity(*severity) + .with_extra("constraint", Value::String(id.to_string())), + ); + } + ConstraintOutcome::NotEvaluable(detail) => { + errors.push( + ValidationError::new( + ErrorKind::FhirpathConstraint, + path.to_string(), + messages::constraint_not_evaluable(id, &detail), + ) + .with_severity(Severity::Warning) + .with_extra("constraint", Value::String(id.to_string())), + ); + } + } + } +} + +async fn execute_bindings( + deferred: &[Deferred], + handlers: &EffectHandlers<'_>, + errors: &mut Vec, +) { + let Some(provider) = handlers.terminology else { + return; + }; + + for d in deferred { + let Deferred::Binding { path, binding, value, type_hint } = d else { + continue; + }; + if binding.strength.as_deref() != Some("required") { + continue; + } + let Some(coded) = coded_value(value, type_hint.as_deref()) else { + continue; // shape not coded (e.g. null gap) — structural checks own it + }; + match provider.validate_code(&binding.value_set, &coded).await { + Ok(true) => {} + Ok(false) => { + errors.push( + ValidationError::new( + ErrorKind::TerminologyBinding, + path.clone(), + messages::terminology_binding(value, &binding.value_set), + ) + .with_extra( + "binding", + serde_json::to_value(binding).expect("binding serializes"), + ), + ); + } + Err(e) => { + let severity = if handlers.terminology_fail_closed { + Severity::Error + } else { + Severity::Warning + }; + errors.push( + ValidationError::new( + ErrorKind::TerminologyBinding, + path.clone(), + messages::terminology_unavailable(&binding.value_set, &e.0), + ) + .with_severity(severity), + ); + } + } + } +} + +/// Determine the coded shape: the element's declared type wins, inference +/// from the value is the fallback (mirroring the reference validator). +fn coded_value(value: &Value, type_hint: Option<&str>) -> Option { + match type_hint { + Some("code") | Some("string") | Some("uri") | Some("canonical") => { + value.as_str().map(|s| CodedValue::Code(s.to_string())) + } + Some("Coding") => value.is_object().then(|| CodedValue::Coding(value.clone())), + Some("CodeableConcept") => { + value.is_object().then(|| CodedValue::CodeableConcept(value.clone())) + } + Some("Quantity") => { + // Quantity binds via its system+code pair — treated as a Coding. + value.is_object().then(|| CodedValue::Coding(value.clone())) + } + _ => match value { + Value::String(s) => Some(CodedValue::Code(s.clone())), + Value::Object(o) if o.contains_key("coding") || o.contains_key("text") => { + Some(CodedValue::CodeableConcept(value.clone())) + } + Value::Object(_) => Some(CodedValue::Coding(value.clone())), + _ => None, + }, + } +} diff --git a/crates/fhir-validator/src/engine/errors.rs b/crates/fhir-validator/src/engine/errors.rs new file mode 100644 index 000000000..66f08f885 --- /dev/null +++ b/crates/fhir-validator/src/engine/errors.rs @@ -0,0 +1,404 @@ +//! The validation error model and — critically — every message template. +//! +//! The upstream FHIR Schema conformance suite compares error lists with exact +//! ordered deep-equality, message strings included. All message text lives in +//! this one file so the contract is pinned in a single place; the unit tests +//! at the bottom hold the literal strings copied from the fixtures and the +//! reference validator (`fhirschema-js/src/index.js`). + +use serde::Serialize; +use serde_json::Value; + +/// Stable error type strings — the cross-implementation contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ErrorKind { + /// A data key matched no element in any schema of the cooperative set. + UnknownElement, + /// A singular value was given where some layer declares `array: true`. + NotArray, + /// An array was given where every layer says singular. + NotSingular, + /// Wrong JSON container type (e.g. `required` applied to a non-object). + Type, + /// A key listed in `required` is absent. + Required, + /// A key listed in `excluded` is present. + Excluded, + /// Array shorter than `min`. + Min, + /// Array longer than `max`. + Max, + /// A slice's matched-item count violates its `min`/`max`. + SliceCardinality, + /// More than one branch of a choice group present at once. + Choice, + /// A present choice branch is not in an allowed `choices` list. + ChoiceExcluded, + /// Value not deeply equal to `fixed`. + FixedValue, + /// Value does not partially match `pattern`. + PatternValue, + /// A `severity: error` FHIRPath constraint evaluated to false. + FhirpathConstraint, + /// A coded value failed its required-strength ValueSet binding. + TerminologyBinding, + // ------------------------------------------------------------------ + // Helios extensions — not pinned by (and never triggered in) the + // upstream conformance fixtures. + // ------------------------------------------------------------------ + /// A primitive value failed its type-class or regex check. + PrimitiveValue, + /// An array item matched no slice under `rules: closed`, or an unmatched + /// item preceded matched items under `rules: openAtEnd`. + SliceUnmatched, + /// Matched items violate the declared slice order (`ordered: true`). + SliceOrder, + /// A referenced schema (root, `base`, or complex `type`) could not be + /// resolved. The reference validator throws here; we report and continue. + UnknownSchema, + /// A profile named in `meta.profile` or the caller's profile list could + /// not be resolved. + UnknownProfile, +} + +/// Issue severity. Internal only — deliberately not serialized, so the +/// conformance error shape stays exactly `{type, path, message, ...extra}`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Severity { + #[default] + Error, + Warning, +} + +/// One validation issue, shaped for the conformance contract. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ValidationError { + #[serde(rename = "type")] + pub kind: ErrorKind, + /// Dot-joined location rooted at the resource type, numeric array + /// indices as bare numbers: `Bundle.entry.0.resource.extra`. + pub path: String, + pub message: String, + /// Structured extras carried by some kinds (`constraint` id, + /// `binding` object). + #[serde(flatten)] + pub extra: serde_json::Map, + /// Internal severity; REST maps it onto OperationOutcome. + #[serde(skip)] + pub severity: Severity, +} + +impl ValidationError { + pub fn new(kind: ErrorKind, path: String, message: String) -> Self { + Self { kind, path, message, extra: serde_json::Map::new(), severity: Severity::Error } + } + + pub fn with_severity(mut self, severity: Severity) -> Self { + self.severity = severity; + self + } + + pub fn with_extra(mut self, key: &str, value: Value) -> Self { + self.extra.insert(key.to_string(), value); + self + } +} + +// --------------------------------------------------------------------- +// Message templates — the single source of truth. +// +// Templates marked [pinned] are exercised verbatim by the upstream fixtures +// and must not change. Templates marked [reference] reproduce the reference +// validator's wording for keywords no fixture exercises yet. Templates +// marked [helios] are our own. +// --------------------------------------------------------------------- + +/// [pinned] `{key} is unknown` +pub(crate) fn msg_unknown_element(key: &str) -> String { + format!("{key} is unknown") +} + +/// [pinned] `{key} is not array` +pub(crate) fn msg_not_array(key: &str) -> String { + format!("{key} is not array") +} + +/// [pinned] `{key} is not singular` +pub(crate) fn msg_not_singular(key: &str) -> String { + format!("{key} is not singular") +} + +/// [pinned] `{key} is required` +pub(crate) fn msg_required(key: &str) -> String { + format!("{key} is required") +} + +/// [reference] `excluded property {key} is present` +pub(crate) fn msg_excluded(key: &str) -> String { + format!("excluded property {key} is present") +} + +/// [reference] `expected object got {json_type}` — the `required`/`excluded` +/// wording (no comma). +pub(crate) fn msg_expected_object_no_comma(json_type: &str) -> String { + format!("expected object got {json_type}") +} + +/// [reference] `expected object, got {json_type}` — the `elements` wording +/// (with comma). Yes, the two differ only by a comma; both are contract. +pub(crate) fn msg_expected_object_comma(json_type: &str) -> String { + format!("expected object, got {json_type}") +} + +/// [reference] `expected {min} > {actual_len}` +pub(crate) fn msg_min(min: u64, actual_len: usize) -> String { + format!("expected {min} > {actual_len}") +} + +/// [reference] `expected {max} < {actual_len}` +pub(crate) fn msg_max(max: u64, actual_len: usize) -> String { + format!("expected {max} < {actual_len}") +} + +/// [pinned] `only one choice for {group} allowed, but multiple found: {a, b}` +pub(crate) fn msg_choice(group: &str, branches: &[String]) -> String { + format!( + "only one choice for {group} allowed, but multiple found: {}", + branches.join(", ") + ) +} + +/// [pinned] `{key} is excluded choice` +pub(crate) fn msg_choice_excluded(key: &str) -> String { + format!("{key} is excluded choice") +} + +/// [pinned] `Slice defines the following min cardinality: '{min}', actual cardinality: '{n}'` +pub(crate) fn msg_slice_min(min: u64, actual: u64) -> String { + format!("Slice defines the following min cardinality: '{min}', actual cardinality: '{actual}'") +} + +/// [pinned] `Slice defines the following max cardinality: '{max}', actual cardinality: '{n}'` +pub(crate) fn msg_slice_max(max: u64, actual: u64) -> String { + format!("Slice defines the following max cardinality: '{max}', actual cardinality: '{actual}'") +} + +/// Render a value for message text: bare strings (like the reference +/// validator's template interpolation), compact JSON for everything else. +pub(crate) fn render_value(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// [helios] fixed-value mismatch. Matches the reference wording for scalars; +/// composites render as compact JSON (the reference coerces JS objects to +/// `[object Object]`, which no fixture pins). +pub(crate) fn msg_fixed_value(expected: &Value, actual: &Value) -> String { + format!( + "Expected value to be exactly equal to 'fixed' pattern '{}', but got: '{}'", + render_value(expected), + render_value(actual) + ) +} + +/// [helios] pattern-value mismatch (same rendering rationale as fixed). +pub(crate) fn msg_pattern_value(expected: &Value, actual: &Value) -> String { + format!( + "Expected value to match 'pattern' '{}', but got: '{}'", + render_value(expected), + render_value(actual) + ) +} + +/// [helios] `rules: closed` violation, at the unmatched item's path. +pub(crate) fn msg_slice_closed_unmatched() -> String { + "item does not match any slice in closed slicing".to_string() +} + +/// [helios] `rules: openAtEnd` violation, at the unmatched item's path. +pub(crate) fn msg_slice_open_at_end() -> String { + "unmatched item must be at the end of the array (openAtEnd slicing)".to_string() +} + +/// [helios] `ordered: true` violation, at the out-of-order item's path. +pub(crate) fn msg_slice_order(slice: &str) -> String { + format!("slice '{slice}' is out of order (ordered slicing)") +} + +/// [helios] a primitive value has the wrong JSON type class. +pub(crate) fn msg_primitive_type(type_name: &str, json_type: &str) -> String { + format!("expected {type_name}, got {json_type}") +} + +/// [helios] a primitive string value failed the type's regex. +pub(crate) fn msg_primitive_regex(type_name: &str, value: &str) -> String { + format!("value '{value}' is not a valid {type_name}") +} + +/// [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 { + format!( + "FHIRPath constraint {id} error: {}", + human.unwrap_or("property \"human\" is not provided") + ) +} + +/// [reference] terminology-binding failure wording. +pub(crate) fn msg_terminology_binding(value: &Value, value_set: &str) -> String { + format!( + "Provided coded value '{}' does not pass validation against the following valueset: '{value_set}'", + render_value(value) + ) +} + +/// [helios] the terminology service could not be reached / errored. +pub(crate) fn msg_terminology_unavailable(value_set: &str, detail: &str) -> String { + format!("terminology validation against '{value_set}' could not be performed: {detail}") +} + +/// [helios] constraint evaluation itself failed (parse error, non-boolean +/// result, typed-model mismatch). +pub(crate) fn msg_constraint_not_evaluable(id: &str, detail: &str) -> String { + format!("FHIRPath constraint {id} could not be evaluated: {detail}") +} + +/// [helios] a schema reference could not be resolved (reference validator +/// throws `could not resolve ["…"]`; we report and continue). +pub(crate) fn msg_unknown_schema(reference: &str) -> String { + format!("could not resolve schema '{reference}'") +} + +/// [helios] a profile reference could not be resolved. +pub(crate) fn msg_unknown_profile(reference: &str) -> String { + format!("could not resolve profile '{reference}'") +} + +/// The reference validator's JSON type vocabulary (`getType`). +pub(crate) fn json_type_name(v: &Value) -> &'static str { + match v { + Value::Array(_) => "array", + Value::Object(_) => "object", + Value::String(_) => "string", + Value::Number(_) => "number", + Value::Bool(_) => "boolean", + Value::Null => "null", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Literal strings copied from the upstream fixtures — the [pinned] set. + #[test] + fn pinned_messages_match_fixture_literals() { + assert_eq!(msg_unknown_element("unknown"), "unknown is unknown"); + assert_eq!(msg_not_array("multielement"), "multielement is not array"); + assert_eq!(msg_not_singular("status"), "status is not singular"); + assert_eq!(msg_required("anotherRequiredElement"), "anotherRequiredElement is required"); + assert_eq!( + msg_choice("choice", &["choiceString".into(), "choiceInteger".into()]), + "only one choice for choice allowed, but multiple found: choiceString, choiceInteger" + ); + assert_eq!(msg_choice_excluded("choiceInteger"), "choiceInteger is excluded choice"); + assert_eq!( + msg_slice_min(1, 0), + "Slice defines the following min cardinality: '1', actual cardinality: '0'" + ); + assert_eq!( + msg_slice_max(1, 2), + "Slice defines the following max cardinality: '1', actual cardinality: '2'" + ); + } + + /// Literal strings copied from fhirschema-js/src/index.js — the + /// [reference] set (no fixture exercises them yet). + #[test] + fn reference_messages_match_source_literals() { + assert_eq!(msg_expected_object_no_comma("string"), "expected object got string"); + assert_eq!(msg_expected_object_comma("string"), "expected object, got string"); + assert_eq!(msg_excluded("gender"), "excluded property gender is present"); + assert_eq!(msg_min(1, 0), "expected 1 > 0"); + assert_eq!(msg_max(2, 3), "expected 2 < 3"); + } + + /// Helios-owned wording, pinned here and by the extended fixtures. + #[test] + fn helios_messages_match_extended_fixture_literals() { + assert_eq!( + msg_fixed_value(&json!("male"), &json!("female")), + "Expected value to be exactly equal to 'fixed' pattern 'male', but got: 'female'" + ); + assert_eq!( + msg_pattern_value(&json!({"system": "http://x"}), &json!({"text": "M"})), + "Expected value to match 'pattern' '{\"system\":\"http://x\"}', but got: '{\"text\":\"M\"}'" + ); + assert_eq!( + msg_slice_closed_unmatched(), + "item does not match any slice in closed slicing" + ); + assert_eq!( + msg_slice_open_at_end(), + "unmatched item must be at the end of the array (openAtEnd slicing)" + ); + assert_eq!(msg_slice_order("home"), "slice 'home' is out of order (ordered slicing)"); + assert_eq!( + serde_json::to_value(ErrorKind::SliceUnmatched).unwrap(), + json!("slice-unmatched") + ); + assert_eq!(serde_json::to_value(ErrorKind::SliceOrder).unwrap(), json!("slice-order")); + } + + #[test] + fn serializes_to_conformance_shape() { + let err = ValidationError::new( + ErrorKind::UnknownElement, + "Patient.unknown".into(), + msg_unknown_element("unknown"), + ); + assert_eq!( + serde_json::to_value(&err).unwrap(), + json!({ + "type": "unknown-element", + "path": "Patient.unknown", + "message": "unknown is unknown" + }) + ); + } + + #[test] + fn kebab_case_kind_strings() { + for (kind, s) in [ + (ErrorKind::UnknownElement, "unknown-element"), + (ErrorKind::NotArray, "not-array"), + (ErrorKind::NotSingular, "not-singular"), + (ErrorKind::Type, "type"), + (ErrorKind::SliceCardinality, "slice-cardinality"), + (ErrorKind::ChoiceExcluded, "choice-excluded"), + (ErrorKind::FixedValue, "fixed-value"), + (ErrorKind::PatternValue, "pattern-value"), + (ErrorKind::FhirpathConstraint, "fhirpath-constraint"), + (ErrorKind::TerminologyBinding, "terminology-binding"), + ] { + assert_eq!(serde_json::to_value(kind).unwrap(), json!(s)); + } + } + + #[test] + fn extra_fields_flatten() { + let err = ValidationError::new( + ErrorKind::FhirpathConstraint, + "Patient".into(), + "FHIRPath constraint pat-1 error: must have a name".into(), + ) + .with_extra("constraint", json!("pat-1")); + let v = serde_json::to_value(&err).unwrap(); + assert_eq!(v["constraint"], json!("pat-1")); + assert!(v.get("severity").is_none(), "severity must not serialize"); + } +} diff --git a/crates/fhir-validator/src/engine/mod.rs b/crates/fhir-validator/src/engine/mod.rs new file mode 100644 index 000000000..86852b3a6 --- /dev/null +++ b/crates/fhir-validator/src/engine/mod.rs @@ -0,0 +1,112 @@ +//! The cooperative validation engine. +//! +//! Validation never flattens schemas: it builds a *set* of schemas (the +//! *schemata*) for every data node — root schema from `resourceType`, +//! profiles, plus every transitively referenced `base` and complex `type` — +//! and every member of the set judges the same node together. See +//! `docs/guide/mental-model.md` in the FHIR Schema repo for the model, and +//! `walk.rs` for the deterministic error-emission order. + +mod errors; +mod path; +mod primitives; +mod slicing; +mod walk; + +pub use errors::{ErrorKind, Severity, ValidationError}; +pub use path::dotted_to_fhirpath; + +/// Crate-internal access to the effect-issue message templates (the full +/// template catalog lives in `errors.rs`, the single source of truth). +pub(crate) mod messages { + pub(crate) use super::errors::{ + msg_constraint_not_evaluable as constraint_not_evaluable, + msg_fhirpath_constraint as fhirpath_constraint, + msg_terminology_binding as terminology_binding, + msg_terminology_unavailable as terminology_unavailable, + }; +} + +use crate::effects::{Deferred, EffectHandlers}; +use crate::resolver::SchemaResolver; +use helios_fhir::FhirVersion; +use serde_json::Value; +use std::sync::Arc; + +/// Policy for profile references (`meta.profile` / caller-supplied) that the +/// resolver cannot resolve. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UnknownProfilePolicy { + /// Report as a warning-severity issue (default). + #[default] + Warn, + /// Report as an error-severity issue. + Error, + /// Skip silently. + Ignore, +} + +/// Options for one validation run. +#[derive(Debug, Clone)] +pub struct ValidationOptions { + /// Extra profile canonicals/names to layer on, in addition to the root + /// schema and (optionally) `meta.profile`. + pub profiles: Vec, + /// Layer on the profiles the resource claims in `meta.profile`. + pub use_meta_profiles: bool, + /// What to do when a profile reference cannot be resolved. + pub unknown_profile: UnknownProfilePolicy, +} + +impl Default for ValidationOptions { + fn default() -> Self { + Self { + profiles: Vec::new(), + use_meta_profiles: true, + unknown_profile: UnknownProfilePolicy::default(), + } + } +} + +/// Result of the pure synchronous walk. +#[derive(Debug, Default)] +pub struct SyncOutcome { + /// Structural issues, in deterministic emission order. + pub errors: Vec, + /// Constraint/binding obligations for the async effects pass (Phase 4). + pub deferred: Vec, +} + +/// The validator: a resolver plus the walk. +pub struct Validator { + resolver: Arc, +} + +impl Validator { + pub fn new(resolver: Arc) -> Self { + Self { resolver } + } + + /// Pure, synchronous structural validation. No I/O, no FHIRPath — + /// constraint/binding obligations are returned as [`Deferred`] entries + /// instead of being executed. + pub fn validate_sync(&self, resource: &Value, opts: &ValidationOptions) -> SyncOutcome { + walk::validate(self.resolver.as_ref(), resource, opts) + } + + /// Full validation: the structural walk plus execution of the deferred + /// FHIRPath constraints and terminology bindings through `effects`. + /// Structural issues come first, then constraint issues, then binding + /// issues (each in walk order). + pub async fn validate( + &self, + resource: &Value, + version: FhirVersion, + opts: &ValidationOptions, + effects: &EffectHandlers<'_>, + ) -> Vec { + let SyncOutcome { mut errors, deferred } = self.validate_sync(resource, opts); + crate::effects::execute(resource, version, &deferred, effects, &mut errors).await; + errors + } +} diff --git a/crates/fhir-validator/src/engine/path.rs b/crates/fhir-validator/src/engine/path.rs new file mode 100644 index 000000000..36b0db617 --- /dev/null +++ b/crates/fhir-validator/src/engine/path.rs @@ -0,0 +1,127 @@ +//! Path tracking during the validation walk. +//! +//! The conformance contract renders paths dot-joined, rooted at the +//! resource type, with array indices as bare numbers: +//! `Bundle.entry.0.resource.extra`. The REST layer additionally needs +//! bracket-indexed FHIRPath (`Bundle.entry[0].resource.extra`) for +//! `OperationOutcome.issue.expression`. + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum PathSeg { + Key(String), + Index(usize), +} + +#[derive(Debug, Clone, Default)] +pub struct PathTracker { + segs: Vec, +} + +impl PathTracker { + /// Root the path at the resource type (empty string when the data has no + /// `resourceType`, mirroring the reference validator). + pub(crate) fn new(root: impl Into) -> Self { + Self { segs: vec![PathSeg::Key(root.into())] } + } + + pub(crate) fn push_key(&mut self, key: &str) { + self.segs.push(PathSeg::Key(key.to_string())); + } + + pub(crate) fn push_index(&mut self, index: usize) { + self.segs.push(PathSeg::Index(index)); + } + + pub(crate) fn pop(&mut self) { + self.segs.pop(); + } + + /// The last `Key` segment — the element name currently being validated. + /// Used by the `choices` keyword, which reports on the branch key. + pub(crate) fn last_key(&self) -> Option<&str> { + self.segs.iter().rev().find_map(|s| match s { + PathSeg::Key(k) => Some(k.as_str()), + PathSeg::Index(_) => None, + }) + } + + /// Dot-joined conformance rendering: `Patient.name.0.given`. + pub(crate) fn render_dotted(&self) -> String { + let mut out = String::new(); + for (i, seg) in self.segs.iter().enumerate() { + if i > 0 { + out.push('.'); + } + match seg { + PathSeg::Key(k) => out.push_str(k), + PathSeg::Index(n) => out.push_str(&n.to_string()), + } + } + out + } +} + +/// Render a dotted conformance path as bracket-indexed FHIRPath: +/// `Patient.name.0.given` → `Patient.name[0].given`. +/// +/// Used by the REST layer for `OperationOutcome.issue.expression`. +pub fn dotted_to_fhirpath(dotted: &str) -> String { + let mut out = String::new(); + for seg in dotted.split('.') { + if !out.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) && !seg.is_empty() { + out.push('['); + out.push_str(seg); + out.push(']'); + } else { + if !out.is_empty() { + out.push('.'); + } + out.push_str(seg); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_dotted_with_numeric_indices() { + let mut p = PathTracker::new("Bundle"); + p.push_key("entry"); + p.push_index(0); + p.push_key("resource"); + p.push_key("extra"); + assert_eq!(p.render_dotted(), "Bundle.entry.0.resource.extra"); + p.pop(); + assert_eq!(p.render_dotted(), "Bundle.entry.0.resource"); + } + + #[test] + fn empty_root_renders_like_reference() { + // No resourceType → root joins to the empty string. + let mut p = PathTracker::new(""); + assert_eq!(p.render_dotted(), ""); + p.push_key("x"); + assert_eq!(p.render_dotted(), ".x"); + } + + #[test] + fn last_key_skips_indices() { + let mut p = PathTracker::new("Patient"); + p.push_key("name"); + p.push_index(2); + assert_eq!(p.last_key(), Some("name")); + } + + #[test] + fn fhirpath_rendering_brackets_indices() { + assert_eq!( + dotted_to_fhirpath("Bundle.entry.0.resource.extra"), + "Bundle.entry[0].resource.extra" + ); + assert_eq!(dotted_to_fhirpath("Patient"), "Patient"); + assert_eq!(dotted_to_fhirpath("Patient.name.10"), "Patient.name[10]"); + } +} diff --git a/crates/fhir-validator/src/engine/primitives.rs b/crates/fhir-validator/src/engine/primitives.rs new file mode 100644 index 000000000..887f7f2fa --- /dev/null +++ b/crates/fhir-validator/src/engine/primitives.rs @@ -0,0 +1,120 @@ +//! Primitive value validation: JSON type-class and regex checks. +//! +//! Runs when a primitive-type schema is a member of a node's cooperative +//! set. The type class comes from the primitive's declared `type` (falling +//! back to `name`); the regex is the FHIR spec pattern carried into the +//! packs by the converter. Upstream conformance fixtures declare primitives +//! as bare `{kind: "primitive-type"}` — no type, no regex — so neither +//! check can fire there and the exact-match contract is unaffected. +//! +//! FHIR spec regexes are implicitly anchored: they must match the whole +//! value, so patterns are compiled as `^(?:...)$`. + +use super::errors::{self, ErrorKind}; +use super::walk::WalkCtx; +use crate::schema::FhirSchema; +use regex::Regex; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +/// Validate a data value against a primitive-type schema. `Null` is skipped: +/// legal as a sidecar-array gap, and shape errors are reported elsewhere. +pub(super) fn validate_primitive(ctx: &mut WalkCtx<'_>, schema: &FhirSchema, data: &Value) { + if data.is_null() || data.is_array() { + return; + } + let Some(type_name) = schema.type_.as_deref().or(schema.name.as_deref()) else { + return; // bare fixture primitive — nothing to check against + }; + + if let Some(expected) = expected_json_class(type_name) + && !expected.matches(data) + { + ctx.error( + ErrorKind::PrimitiveValue, + errors::msg_primitive_type(type_name, errors::json_type_name(data)), + ); + return; + } + + if let Some(pattern) = &schema.regex + && let Some(s) = data.as_str() + && let Some(re) = compiled(pattern) + && !re.is_match(s) + { + ctx.error(ErrorKind::PrimitiveValue, errors::msg_primitive_regex(type_name, s)); + } +} + +/// The JSON type class a FHIR primitive requires. +#[derive(Clone, Copy)] +enum JsonClass { + Boolean, + Integer, + Number, + String, +} + +impl JsonClass { + fn matches(self, v: &Value) -> bool { + match self { + JsonClass::Boolean => v.is_boolean(), + JsonClass::Integer => v.as_number().is_some_and(|n| n.is_i64() || n.is_u64()), + JsonClass::Number => v.is_number(), + JsonClass::String => v.is_string(), + } + } +} + +fn expected_json_class(type_name: &str) -> Option { + Some(match type_name { + "boolean" => JsonClass::Boolean, + "integer" | "positiveInt" | "unsignedInt" | "integer64" => JsonClass::Integer, + "decimal" => JsonClass::Number, + // Everything else — string, uri, code, dateTime, base64Binary, ... — + // is a JSON string. Unknown primitive names get no class check. + "string" | "uri" | "url" | "canonical" | "oid" | "uuid" | "id" | "code" | "markdown" + | "base64Binary" | "instant" | "date" | "dateTime" | "time" | "xhtml" => { + JsonClass::String + } + _ => return None, + }) +} + +/// Process-wide compiled-regex cache (patterns repeat across every node of +/// every resource). Invalid patterns are cached as misses so they are only +/// reported... never: they are simply skipped — the converter emits spec +/// patterns, and a bad pattern must not fail validation. +fn compiled(pattern: &str) -> Option> { + static CACHE: OnceLock>>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| RwLock::new(HashMap::new())); + + if let Some(hit) = cache.read().expect("regex cache lock").get(pattern) { + return hit.clone(); + } + let compiled = Regex::new(&format!("^(?:{pattern})$")).ok().map(Arc::new); + cache + .write() + .expect("regex cache lock") + .insert(pattern.to_string(), compiled.clone()); + compiled +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchoring_is_whole_value() { + let re = compiled("[0-9]+").unwrap(); + assert!(re.is_match("123")); + assert!(!re.is_match("a123")); + assert!(!re.is_match("123b")); + } + + #[test] + fn invalid_pattern_is_skipped() { + assert!(compiled("([unclosed").is_none()); + } +} diff --git a/crates/fhir-validator/src/engine/slicing.rs b/crates/fhir-validator/src/engine/slicing.rs new file mode 100644 index 000000000..838bc5282 --- /dev/null +++ b/crates/fhir-validator/src/engine/slicing.rs @@ -0,0 +1,214 @@ +//! Array slicing: the mark/sweep pass. +//! +//! For every slicing-bearing schema in the element's set: +//! - **mark**: match each array item against each slice's pattern +//! (`_.isMatch`-style partial deep equality), counting matches per slice; +//! - items matched by at least one slice **with a schema** are validated +//! once, cooperatively, against the element set extended with *all* their +//! matched slice schemas (the reference validator validates once per +//! matched slice against `elset ∪ that slice`, and then re-validates every +//! item in the plain pass — duplicating base-set errors; we consume +//! matched items instead, which is indistinguishable on the conformance +//! suite and saner on real resources); +//! - **sweep**: `rules: closed` (with `@default` absorption) and +//! `rules: openAtEnd` violations, `ordered` violations, then per-slice +//! `min`/`max` cardinality in slice declaration order. +//! +//! Divergences from the reference validator (which implements only pattern +//! matching + cardinality): `closed`/`openAtEnd`/`ordered`/`@default` are +//! 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. + +use super::errors::{self, ErrorKind}; +use super::walk::{add_schemas_to_set, is_partial_match, validate_node, SchemaSet, WalkCtx}; +use crate::schema::{Slice, Slicing}; +use indexmap::IndexMap; +use serde_json::Value; +use std::collections::HashSet; +use std::sync::Arc; + +/// The reserved slice name that absorbs unmatched items in closed slicing. +const DEFAULT_SLICE: &str = "@default"; + +struct LayerMark { + slicing: Slicing, + /// Matched-item count per slice, in declaration order. + counters: IndexMap, + /// For each array item, the slice names it matched (declaration order). + item_matches: Vec>, +} + +/// Run the slicing pass over an array element. Returns the indices of items +/// that were already fully validated (consumed) here. +pub(super) fn validate_slices( + ctx: &mut WalkCtx<'_>, + elset: &SchemaSet, + items: &[Value], +) -> HashSet { + let slicings: Vec = elset + .schemas() + .filter_map(|s| s.slicing.clone()) + .collect(); + if slicings.is_empty() { + return HashSet::new(); + } + + // ---- mark ---- + let mut marks: Vec = Vec::with_capacity(slicings.len()); + for slicing in slicings { + let mut counters: IndexMap = + slicing.slices.keys().map(|k| (k.clone(), 0)).collect(); + let mut item_matches: Vec> = vec![Vec::new(); items.len()]; + for (index, item) in items.iter().enumerate() { + for (name, slice) in &slicing.slices { + if name == DEFAULT_SLICE { + continue; + } + if slice_matches(slice, item) { + *counters.get_mut(name).expect("counter exists") += 1; + item_matches[index].push(name.clone()); + } + } + } + // `@default` (closed slicing only) absorbs items no other slice + // matched; count them so its own min/max can apply. + if slicing.slices.contains_key(DEFAULT_SLICE) + && slicing.rules.as_deref() == Some("closed") + { + let absorbed = item_matches.iter().filter(|m| m.is_empty()).count() as u64; + *counters.get_mut(DEFAULT_SLICE).expect("counter exists") = absorbed; + } + marks.push(LayerMark { slicing, counters, item_matches }); + } + + // ---- validate matched items (item order), consuming them ---- + let mut consumed: HashSet = HashSet::new(); + for (index, item) in items.iter().enumerate() { + let mut slice_schemas: Vec<(String, Arc)> = Vec::new(); + for mark in &marks { + for name in &mark.item_matches[index] { + if let Some(schema) = &mark.slicing.slices[name].schema { + slice_schemas.push((name.clone(), Arc::clone(schema))); + } + } + } + if slice_schemas.is_empty() { + continue; + } + let mut extended = elset.clone(); + for (name, schema) in &slice_schemas { + add_schemas_to_set(ctx, &mut extended, Arc::clone(schema), name); + } + ctx.path.push_index(index); + validate_node(ctx, &extended, item); + ctx.path.pop(); + consumed.insert(index); + } + + // ---- sweep: rules, order, cardinality (per layer, elset order) ---- + for mark in &marks { + let rules = mark.slicing.rules.as_deref().unwrap_or("open"); + + if rules == "closed" { + let default_slice = mark.slicing.slices.get(DEFAULT_SLICE); + for (index, item) in items.iter().enumerate() { + if !mark.item_matches[index].is_empty() { + continue; + } + match default_slice { + Some(Slice { schema: Some(schema), .. }) => { + let mut extended = elset.clone(); + add_schemas_to_set(ctx, &mut extended, Arc::clone(schema), DEFAULT_SLICE); + ctx.path.push_index(index); + validate_node(ctx, &extended, item); + ctx.path.pop(); + consumed.insert(index); + } + Some(_) => { + // @default without a schema: absorbed, nothing extra + // to check; the plain pass still validates the item. + } + None => { + ctx.path.push_index(index); + ctx.error( + ErrorKind::SliceUnmatched, + errors::msg_slice_closed_unmatched(), + ); + ctx.path.pop(); + } + } + } + } else if rules == "openAtEnd" + && let Some(last_matched) = + mark.item_matches.iter().rposition(|m| !m.is_empty()) + { + for index in 0..last_matched { + if mark.item_matches[index].is_empty() { + ctx.path.push_index(index); + ctx.error(ErrorKind::SliceUnmatched, errors::msg_slice_open_at_end()); + ctx.path.pop(); + } + } + } + + if mark.slicing.ordered == Some(true) { + let mut max_order_seen: Option = None; + for (index, matches) in mark.item_matches.iter().enumerate() { + let Some(first) = matches.first() else { + continue; + }; + let Some(order) = mark.slicing.slices[first].order else { + continue; + }; + if let Some(seen) = max_order_seen + && order < seen + { + ctx.path.push_index(index); + ctx.error(ErrorKind::SliceOrder, errors::msg_slice_order(first)); + ctx.path.pop(); + } + max_order_seen = Some(max_order_seen.unwrap_or(0).max(order)); + } + } + + for (name, slice) in &mark.slicing.slices { + let found = mark.counters[name]; + if let Some(min) = slice.min + && min > 0 + && found < min + { + ctx.error(ErrorKind::SliceCardinality, errors::msg_slice_min(min, found)); + } + // Unlike the reference validator (which skips falsy bounds), + // `max: 0` — a prohibited slice, a real FHIR pattern — is + // enforced. + if let Some(max) = slice.max + && found > max + { + ctx.error(ErrorKind::SliceCardinality, errors::msg_slice_max(max, found)); + } + } + } + + 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 { + let Some(match_) = &slice.match_ else { + return false; + }; + match match_.value.as_ref() { + Some(pattern) => is_partial_match(item, pattern), + None => true, + } +} diff --git a/crates/fhir-validator/src/engine/walk.rs b/crates/fhir-validator/src/engine/walk.rs new file mode 100644 index 000000000..c2a53cd23 --- /dev/null +++ b/crates/fhir-validator/src/engine/walk.rs @@ -0,0 +1,726 @@ +//! The cooperative validation walk. +//! +//! Port of the FHIR Schema validation algorithm (`docs/algorythm.md` and the +//! reference validator `fhirschema-js/src/index.js`), with the divergences +//! called out inline. The behavioral contract is the vendored conformance +//! suite; where the reference validator and this port differ, no fixture +//! distinguishes them and the difference is documented. +//! +//! # Deterministic error-emission order +//! +//! 1. Schema sets iterate in insertion order, ancestors first: a schema's +//! `base` chain is added before the schema itself (pinned by fixture +//! `4_required.json`: the base's `required` errors precede the profile's). +//! 2. Within one schema, keyword validators run in the canonical order +//! `fixed, pattern, constraints, required, excluded, binding, elements, +//! choices` (the reference validator follows JSON key declaration order; +//! no fixture distinguishes). +//! 3. Data keys are walked in document order (`serde_json`'s +//! `preserve_order`), array items in index order. +//! 4. Node-level keyword errors precede per-key errors; choice-conflict +//! errors (`postValidate`) come last for each node. +//! +//! # Known divergences from the reference validator +//! +//! - Unresolvable schema references produce an `unknown-schema` error and +//! validation continues; the reference validator throws. +//! - `excluded` is enforced (the reference implementation crashes on it). +//! - Dynamic `Resource` schemas are resolved per array item; the reference +//! validator leaks them into the shared element set across siblings. +//! - A choice branch declared by several layers is counted once per node in +//! the multiple-choices-present check (the reference validator counts it +//! once per layer, which would false-positive on real profiles). +//! - The choice-group *declarer* schema joins a branch element's set under +//! the key `{layer}.{group}` (the reference validator uses `{layer}.{key}`, +//! which silently overwrites the branch schema and loses its constraints). + +use super::errors::{self, ErrorKind, Severity, ValidationError}; +use super::path::PathTracker; +use super::{SyncOutcome, UnknownProfilePolicy, ValidationOptions}; +use crate::effects::Deferred; +use crate::resolver::SchemaResolver; +use crate::schema::FhirSchema; +use indexmap::IndexMap; +use serde_json::Value; +use std::collections::HashSet; +use std::sync::Arc; + +/// The special element type resolved dynamically from `data.resourceType`. +const RESOURCE_TYPE_NAME: &str = "Resource"; + +/// A cooperative schema set (the *schemata*): insertion-ordered, ancestors +/// first, deduplicated both by resolution key and by schema identity. +#[derive(Clone, Default)] +pub(super) struct SchemaSet { + map: IndexMap>, + /// Identity dedup (`Arc` pointer): the same schema reachable under + /// several aliases (name and canonical URL) is added once. + seen: HashSet, +} + +impl SchemaSet { + fn new() -> Self { + Self::default() + } + + fn is_empty(&self) -> bool { + self.map.is_empty() + } + + fn contains_key(&self, key: &str) -> bool { + self.map.contains_key(key) + } + + fn iter(&self) -> impl Iterator)> { + self.map.iter().map(|(k, v)| (k.as_str(), v)) + } + + pub(super) fn schemas(&self) -> impl Iterator> { + self.map.values() + } +} + +pub(super) struct WalkCtx<'a> { + resolver: &'a dyn SchemaResolver, + errors: Vec, + deferred: Vec, + pub(super) path: PathTracker, +} + +impl WalkCtx<'_> { + pub(super) fn error(&mut self, kind: ErrorKind, message: String) { + self.errors.push(ValidationError::new(kind, self.path.render_dotted(), message)); + } + + fn error_with_severity(&mut self, kind: ErrorKind, message: String, severity: Severity) { + self.errors.push( + ValidationError::new(kind, self.path.render_dotted(), message) + .with_severity(severity), + ); + } +} + +pub(super) fn validate( + resolver: &dyn SchemaResolver, + resource: &Value, + opts: &ValidationOptions, +) -> SyncOutcome { + let resource_type = resource + .get("resourceType") + .and_then(Value::as_str) + .unwrap_or_default(); + + let mut ctx = WalkCtx { + resolver, + errors: Vec::new(), + deferred: Vec::new(), + path: PathTracker::new(resource_type), + }; + + // Root schema-set: resourceType, then meta.profile claims, then + // caller-supplied profiles (the draft-algorithm order). + let mut set = SchemaSet::new(); + if !resource_type.is_empty() { + match resolver.resolve(resource_type) { + Some(schema) => add_schemas_to_set(&mut ctx, &mut set, schema, resource_type), + None => ctx.error( + ErrorKind::UnknownSchema, + errors::msg_unknown_schema(resource_type), + ), + } + } + if opts.use_meta_profiles { + for profile in meta_profiles(resource) { + add_profile(&mut ctx, &mut set, &profile, opts.unknown_profile); + } + } + for profile in &opts.profiles { + add_profile(&mut ctx, &mut set, profile, opts.unknown_profile); + } + + validate_node(&mut ctx, &set, resource); + + SyncOutcome { errors: ctx.errors, deferred: ctx.deferred } +} + +/// `meta.profile` entries, in document order. +fn meta_profiles(resource: &Value) -> Vec { + resource + .get("meta") + .and_then(|m| m.get("profile")) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn add_profile( + ctx: &mut WalkCtx<'_>, + set: &mut SchemaSet, + profile: &str, + policy: UnknownProfilePolicy, +) { + match ctx.resolver.resolve(profile) { + Some(schema) => add_schemas_to_set(ctx, set, schema, profile), + None => match policy { + UnknownProfilePolicy::Warn => ctx.error_with_severity( + ErrorKind::UnknownProfile, + errors::msg_unknown_profile(profile), + Severity::Warning, + ), + UnknownProfilePolicy::Error => { + ctx.error(ErrorKind::UnknownProfile, errors::msg_unknown_profile(profile)) + } + UnknownProfilePolicy::Ignore => {} + }, + } +} + +/// Pull `schema` into `set` together with everything it references: +/// its `base` chain first (ancestors-first ordering), then its complex +/// `type` (skipped for primitive leaves), then any `elementReference` +/// target, then the schema itself. +pub(super) fn add_schemas_to_set( + ctx: &mut WalkCtx<'_>, + set: &mut SchemaSet, + schema: Arc, + key: &str, +) { + let identity = Arc::as_ptr(&schema) as usize; + if !set.seen.insert(identity) { + return; + } + + if let Some(base) = schema.base.clone() + && !set.contains_key(&base) { + match ctx.resolver.resolve(&base) { + Some(base_schema) => add_schemas_to_set(ctx, set, base_schema, &base), + None => ctx.error(ErrorKind::UnknownSchema, errors::msg_unknown_schema(&base)), + } + } + + if let Some(type_ref) = schema.type_.clone() + && !schema.is_primitive() && !set.contains_key(&type_ref) { + match ctx.resolver.resolve(&type_ref) { + Some(type_schema) => add_schemas_to_set(ctx, set, type_schema, &type_ref), + None => { + ctx.error(ErrorKind::UnknownSchema, errors::msg_unknown_schema(&type_ref)) + } + } + } + + // `elementReference` pulls another element's schema into the set — + // the mechanism behind recursive structures like Questionnaire.item. + // Cycles terminate via the identity dedup above. + if let Some(segments) = schema.element_reference.clone() { + let ref_key = segments.join("."); + match resolve_element_reference(ctx.resolver, &segments) { + Some(target) => add_schemas_to_set(ctx, set, target, &ref_key), + None => ctx.error(ErrorKind::UnknownSchema, errors::msg_unknown_schema(&ref_key)), + } + } + + set.map.insert(key.to_string(), schema); +} + +/// Navigate an element reference: `["Questionnaire", "elements", "item"]` — +/// a root schema name followed by alternating `"elements"` / element-name +/// segments. +fn resolve_element_reference( + resolver: &dyn SchemaResolver, + segments: &[String], +) -> Option> { + let (root, rest) = segments.split_first()?; + let mut current = resolver.resolve(root)?; + let mut iter = rest.iter(); + while let Some(marker) = iter.next() { + if marker != "elements" { + return None; + } + let name = iter.next()?; + let next = current.elements.as_ref()?.get(name)?.clone(); + current = next; + } + Some(current) +} + +/// Validate one data node against a cooperative schema set. +pub(super) fn validate_node(ctx: &mut WalkCtx<'_>, set: &SchemaSet, data: &Value) { + // Dynamic `Resource` resolution: an element typed `Resource` takes its + // concrete schema from the nested `resourceType` at validation time + // (Bundle.entry.resource, contained, Parameters.parameter.resource). + let mut extended; + let set = if data.is_object() + && set + .schemas() + .any(|s| s.type_.as_deref() == Some(RESOURCE_TYPE_NAME)) + { + extended = set.clone(); + if let Some(rt) = data.get("resourceType").and_then(Value::as_str) { + match ctx.resolver.resolve(rt) { + Some(schema) => add_schemas_to_set(ctx, &mut extended, schema, rt), + None => ctx.error(ErrorKind::UnknownSchema, errors::msg_unknown_schema(rt)), + } + } + // No resourceType on a Resource-typed node: nothing to resolve; the + // static layers (e.g. the base `Resource` schema) still apply. + &extended + } else { + set + }; + + eval_validators(ctx, set, data); + + let Some(obj) = data.as_object() else { + return; + }; + + // Choice branches seen on this node, keyed by choice group, in first- + // encounter order. + let mut multi_choice: IndexMap> = IndexMap::new(); + + for (key, value) in obj { + ctx.path.push_key(key); + // `_foo` sidecars carry the Element part (id/extension) of a + // primitive `foo` — they pair with the base element rather than + // resolving as elements themselves. + if let Some(base_key) = key.strip_prefix('_') + && !base_key.is_empty() + { + handle_sidecar(ctx, set, key, base_key, value); + } else { + eval_element(ctx, set, &mut multi_choice, key, value); + } + ctx.path.pop(); + } + + // postValidate: more than one branch of the same choice group present. + for (group, branches) in &multi_choice { + if branches.len() > 1 { + ctx.path.push_key(group); + ctx.error(ErrorKind::Choice, errors::msg_choice(group, branches)); + ctx.path.pop(); + } + } +} + +/// Run every keyword validator of every schema in the set against the node. +fn eval_validators(ctx: &mut WalkCtx<'_>, set: &SchemaSet, data: &Value) { + // Clone the Arcs up front: keyword validators need `&mut ctx` while the + // set is borrowed, and Arc clones are cheap. + let schemas: Vec> = set.schemas().cloned().collect(); + for schema in &schemas { + if schema.is_primitive() { + super::primitives::validate_primitive(ctx, schema, data); + } + if let Some(fixed) = &schema.fixed + && data != fixed { + ctx.error(ErrorKind::FixedValue, errors::msg_fixed_value(fixed, data)); + } + if let Some(pattern) = &schema.pattern + && !is_partial_match(data, pattern) { + ctx.error(ErrorKind::PatternValue, errors::msg_pattern_value(pattern, data)); + } + if let Some(constraints) = &schema.constraints { + let path = ctx.path.render_dotted(); + for (id, c) in constraints { + ctx.deferred.push(Deferred::Constraint { + path: path.clone(), + id: id.clone(), + expression: c.expression.clone(), + human: c.human.clone(), + severity: c.severity.clone(), + }); + } + } + if let Some(required) = &schema.required { + validate_required(ctx, set, required, data); + } + if let Some(excluded) = &schema.excluded { + validate_excluded(ctx, excluded, data); + } + if let Some(binding) = &schema.binding { + ctx.deferred.push(Deferred::Binding { + path: ctx.path.render_dotted(), + binding: binding.clone(), + value: data.clone(), + type_hint: schema.type_.clone(), + }); + } + if schema.elements.is_some() && !data.is_object() { + ctx.error( + ErrorKind::Type, + errors::msg_expected_object_comma(errors::json_type_name(data)), + ); + } + if let Some(choices) = &schema.choices { + // Runs when a choice-group declarer joins a branch element's + // set; the branch key is the last named path segment. + if let Some(branch) = ctx.path.last_key() + && !choices.iter().any(|c| c == branch) { + let message = errors::msg_choice_excluded(branch); + ctx.error(ErrorKind::ChoiceExcluded, message); + } + } + } +} + +fn validate_required(ctx: &mut WalkCtx<'_>, set: &SchemaSet, required: &[String], data: &Value) { + let Some(obj) = data.as_object() else { + ctx.error( + ErrorKind::Type, + errors::msg_expected_object_no_comma(errors::json_type_name(data)), + ); + return; + }; + for key in required { + ctx.path.push_key(key); + if !obj.contains_key(key) && !choice_branch_satisfies(set, key, obj) { + ctx.error(ErrorKind::Required, errors::msg_required(key)); + } + ctx.path.pop(); + } +} + +/// Helios extension: a required key that is a choice group (`foo` for +/// `foo[x]`) is satisfied by any declared branch (`fooBoolean`, ...) being +/// present. The converter maps `min >= 1` on `foo[x]` to `required: ["foo"]`, +/// and the literal key never appears in data. No upstream fixture combines +/// `required` with choices, so conformance is unaffected. +fn choice_branch_satisfies( + set: &SchemaSet, + key: &str, + obj: &serde_json::Map, +) -> bool { + set.schemas().any(|schema| { + schema + .elements + .as_ref() + .and_then(|els| els.get(key)) + .and_then(|el| el.choices.as_ref()) + .is_some_and(|branches| branches.iter().any(|b| obj.contains_key(b))) + }) +} + +fn validate_excluded(ctx: &mut WalkCtx<'_>, excluded: &[String], data: &Value) { + let Some(obj) = data.as_object() else { + ctx.error( + ErrorKind::Type, + errors::msg_expected_object_no_comma(errors::json_type_name(data)), + ); + return; + }; + for key in excluded { + ctx.path.push_key(key); + if obj.contains_key(key) { + ctx.error(ErrorKind::Excluded, errors::msg_excluded(key)); + } + ctx.path.pop(); + } +} + +/// Validate one data key against the element schemas collected from every +/// layer of the set. +fn eval_element( + ctx: &mut WalkCtx<'_>, + set: &SchemaSet, + multi_choice: &mut IndexMap>, + key: &str, + value: &Value, +) { + let mut elset = SchemaSet::new(); + collect_schemas_for_element(ctx, set, &mut elset, multi_choice, key); + + if elset.is_empty() { + ctx.error(ErrorKind::UnknownElement, errors::msg_unknown_element(key)); + return; + } + + // Array-ness is cooperative: an element is an array if ANY layer says so. + let expect_array = elset.schemas().any(|s| s.array == Some(true)); + match (expect_array, value.is_array()) { + (true, false) => ctx.error(ErrorKind::NotArray, errors::msg_not_array(key)), + (false, true) => ctx.error(ErrorKind::NotSingular, errors::msg_not_singular(key)), + _ => {} + } + // The walk continues after a shape mismatch, mirroring the reference + // validator (the mismatched value still validates against the set). + + if let Some(items) = value.as_array() { + // Whole-collection keywords, per schema: min then max. + let schemas: Vec> = elset.schemas().cloned().collect(); + for schema in &schemas { + if let Some(min) = schema.min + && (items.len() as u64) < min { + ctx.error(ErrorKind::Min, errors::msg_min(min, items.len())); + } + if let Some(max) = schema.max + && (items.len() as u64) > max { + ctx.error(ErrorKind::Max, errors::msg_max(max, items.len())); + } + } + + // Slicing (mark/sweep). Items validated cooperatively with their + // matched slice schemas are consumed — the plain per-item pass below + // skips them so base-set errors are not emitted twice. + let consumed = super::slicing::validate_slices(ctx, &elset, items); + + for (index, item) in items.iter().enumerate() { + if consumed.contains(&index) { + continue; + } + ctx.path.push_index(index); + validate_node(ctx, &elset, item); + ctx.path.pop(); + } + } else { + validate_node(ctx, &elset, value); + } +} + +/// Validate a `_foo` primitive-extension sidecar. +/// +/// The sidecar pairs with the base element `foo`: it is only legal when some +/// layer declares `foo` with a primitive type, it mirrors `foo`'s +/// array-vs-singular shape, and its content (per item for arrays, with +/// `null` gaps allowed) validates against the resolver's `Element` schema. +fn handle_sidecar( + ctx: &mut WalkCtx<'_>, + set: &SchemaSet, + key: &str, + base_key: &str, + value: &Value, +) { + // Collect the base element's sub-set; choice bookkeeping is discarded + // (a sidecar is not a branch occurrence). + let mut scratch: IndexMap> = IndexMap::new(); + let mut base_elset = SchemaSet::new(); + collect_schemas_for_element(ctx, set, &mut base_elset, &mut scratch, base_key); + + // Stray sidecar, or sidecar on a non-primitive element: unknown. + if base_elset.is_empty() || !base_elset.schemas().any(|s| s.is_primitive()) { + ctx.error(ErrorKind::UnknownElement, errors::msg_unknown_element(key)); + return; + } + + // The sidecar mirrors the base element's shape. + let expect_array = base_elset.schemas().any(|s| s.array == Some(true)); + match (expect_array, value.is_array()) { + (true, false) => ctx.error(ErrorKind::NotArray, errors::msg_not_array(key)), + (false, true) => ctx.error(ErrorKind::NotSingular, errors::msg_not_singular(key)), + _ => {} + } + + // Validate the Element part(s). Without a resolvable `Element` schema + // (core packs always carry one) the content is left unchecked. + let Some(element_schema) = ctx.resolver.resolve("Element") else { + return; + }; + let mut element_set = SchemaSet::new(); + add_schemas_to_set(ctx, &mut element_set, element_schema, "Element"); + + match value { + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + if item.is_null() { + continue; + } + ctx.path.push_index(index); + validate_node(ctx, &element_set, item); + ctx.path.pop(); + } + } + Value::Null => {} + other => validate_node(ctx, &element_set, other), + } +} + +/// Gather the element sub-set for `key` across all layers: each layer's +/// `elements[key]` (plus its transitive `base`/`type`), and — when the key +/// is a choice branch — every layer's declarer schema for the choice group. +fn collect_schemas_for_element( + ctx: &mut WalkCtx<'_>, + set: &SchemaSet, + elset: &mut SchemaSet, + multi_choice: &mut IndexMap>, + key: &str, +) { + let layers: Vec<(String, Arc)> = set + .iter() + .map(|(name, schema)| (name.to_string(), Arc::clone(schema))) + .collect(); + + // Every layer's `extensions` map compiles into a synthetic slicing on + // the `extension` element, matched by `{url}` — authoring sugar rewritten + // into the core slicing primitive at validation time. + if key == "extension" { + compile_extensions(ctx, &layers, elset); + } + + let mut choice_group: Option = None; + for (layer_name, layer) in &layers { + let Some(subschema) = layer.elements.as_ref().and_then(|els| els.get(key)) else { + continue; + }; + // Choice-group declarers are not element schemas for their own key: + // a bare group key in data (e.g. `"choice": ...`) is unknown. + if subschema.choices.is_none() { + let subset_key = format!("{layer_name}.{key}"); + add_schemas_to_set(ctx, elset, Arc::clone(subschema), &subset_key); + } + if let Some(group) = &subschema.choice_of { + choice_group = Some(group.clone()); + let entry = multi_choice.entry(group.clone()).or_default(); + // Count each branch once per node, even when several layers + // declare it. + if !entry.iter().any(|k| k == key) { + entry.push(key.to_string()); + } + } + } + + // The key is a choice branch: every layer's declarer for the group joins + // the branch's set, so `choices` narrowing applies cooperatively. + if let Some(group) = choice_group { + for (layer_name, layer) in &layers { + if let Some(declarer) = layer.elements.as_ref().and_then(|els| els.get(&group)) { + let subset_key = format!("{layer_name}.{group}"); + add_schemas_to_set(ctx, elset, Arc::clone(declarer), &subset_key); + } + } + } +} + +/// Compile `extensions` maps from every layer into one synthetic +/// slicing-bearing schema on the `extension` element. Each entry becomes a +/// slice matched by `{url: }` carrying the entry's `min`/`max`; its +/// slice schema is the entry (minus url/min/max) overlaid by the schema the +/// extension's url resolves to. +/// +/// An unresolvable extension url is reported as `unknown-schema` and the +/// entry-only schema is used (the reference validator throws instead). +fn compile_extensions( + ctx: &mut WalkCtx<'_>, + layers: &[(String, Arc)], + elset: &mut SchemaSet, +) { + use crate::schema::{Match, Slice, Slicing}; + + let mut slices: IndexMap = IndexMap::new(); + for (_, layer) in layers { + let Some(extensions) = &layer.extensions else { + continue; + }; + for (ext_name, entry) in extensions { + let Some(url) = entry.url.clone() else { + continue; + }; + let resolved = ctx.resolver.resolve(&url); + if resolved.is_none() { + ctx.error(ErrorKind::UnknownSchema, errors::msg_unknown_schema(&url)); + } + let merged = merge_extension_schema(entry, resolved.as_deref()); + slices.insert( + ext_name.clone(), + Slice { + match_: Some(Match { + type_: Some("pattern".to_string()), + value: Some(serde_json::json!({ "url": url })), + resolve_ref: None, + }), + min: entry.min, + max: entry.max, + order: None, + reslice: None, + slice_is_constraining: None, + schema: Some(Arc::new(merged)), + }, + ); + } + } + + if !slices.is_empty() { + let synthetic = FhirSchema { + slicing: Some(Slicing { slices, rules: None, ordered: None }), + ..Default::default() + }; + add_schemas_to_set(ctx, elset, Arc::new(synthetic), "extension"); + } +} + +/// Shallow overlay merge for extension slice schemas, mirroring the +/// reference validator's `Object.assign(entry-minus-url/min/max, resolved)`: +/// fields present on the resolved extension schema win; entry fields fill +/// the gaps. +fn merge_extension_schema(entry: &FhirSchema, resolved: Option<&FhirSchema>) -> FhirSchema { + let mut out = entry.clone(); + out.url = None; + out.min = None; + out.max = None; + + let Some(r) = resolved else { + return out; + }; + + macro_rules! overlay { + ($($field:ident),* $(,)?) => { + $(if r.$field.is_some() { + out.$field = r.$field.clone(); + })* + }; + } + overlay!( + url, name, base, kind, derivation, type_, array, scalar, min, max, elements, required, + excluded, element_reference, choices, choice_of, fixed, pattern, binding, constraints, + refers, slicing, extensions, modifier, must_support, summary, regex, + ); + out +} + +/// 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. +pub(crate) fn is_partial_match(data: &Value, pattern: &Value) -> bool { + match pattern { + Value::Object(pattern_map) => { + let Some(data_map) = data.as_object() else { + return false; + }; + pattern_map.iter().all(|(k, pv)| { + data_map.get(k).is_some_and(|dv| is_partial_match(dv, pv)) + }) + } + Value::Array(pattern_items) => { + let Some(data_items) = data.as_array() else { + return false; + }; + pattern_items.len() <= data_items.len() + && pattern_items + .iter() + .zip(data_items.iter()) + .all(|(pv, dv)| is_partial_match(dv, pv)) + } + scalar => data == scalar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn partial_match_semantics() { + assert!(is_partial_match(&json!({"use": "home", "city": "X"}), &json!({"use": "home"}))); + assert!(!is_partial_match(&json!({"use": "work"}), &json!({"use": "home"}))); + assert!(!is_partial_match(&json!("home"), &json!({"use": "home"}))); + assert!(is_partial_match(&json!({"a": {"b": 1, "c": 2}}), &json!({"a": {"b": 1}}))); + assert!(is_partial_match(&json!([1, 2, 3]), &json!([1, 2]))); + assert!(!is_partial_match(&json!([2, 1]), &json!([1]))); + assert!(is_partial_match(&json!(5), &json!(5))); + } +} diff --git a/crates/fhir-validator/src/fhirpath_effects.rs b/crates/fhir-validator/src/fhirpath_effects.rs new file mode 100644 index 000000000..87b974325 --- /dev/null +++ b/crates/fhir-validator/src/fhirpath_effects.rs @@ -0,0 +1,198 @@ +//! FHIRPath-backed [`ConstraintEvaluator`] (feature `fhirpath`). +//! +//! Per resource: the raw JSON is parsed **once** into the typed +//! `helios_fhir` model (the `crates/sof` pattern) — helios-fhirpath +//! evaluates against typed resources, not raw JSON. Each constraint is then +//! evaluated as one combined expression: for a constraint attached below +//! the root, the validator path is rendered as a relative FHIRPath and the +//! invariant is wrapped in `.all(...)`: +//! +//! ```text +//! path Patient.contact.0, expr "name.exists() or telecom.exists()" +//! → contact[0].all(name.exists() or telecom.exists()) +//! ``` +//! +//! `all()` gives the correct per-node focus *and* the FHIR invariant +//! semantics for absent nodes (empty collection → pass). Root-attached +//! constraints evaluate as-is. +//! +//! Result coercion (FHIRPath 5.1.1 / FHIR invariant semantics): empty → +//! pass; a single boolean → itself; anything else → not evaluable +//! (surfaced as a warning issue by the effects layer). Note the JS +//! reference validator fails on empty — a known bug there, upstream +//! fixtures don't pin it. +//! +//! Parsed expression ASTs are memoized per evaluator instance — core +//! invariants repeat across every resource of a type. +//! +//! Known limitation (documented in the plan): helios-fhirpath resolves +//! `%resource`/`%rootResource` to the root resource, so constraints that +//! depend on nested-resource `%resource` semantics may misfire; and the +//! `.all()` wrapping makes `%context` the root rather than the node. + +use crate::effects::{ConstraintEvaluator, ConstraintOutcome, DeferredConstraint}; +use helios_fhir::{FhirResource, FhirVersion}; +use helios_fhirpath::{EvaluationContext, EvaluationResult}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Mutex; + +/// Evaluates invariants with helios-fhirpath. Cheap to construct; hold one +/// per service for the lifetime of the process to benefit from the AST +/// cache. +#[derive(Default)] +pub struct FhirPathConstraintEvaluator { + /// Combined-expression parse cache. + asts: Mutex>>, +} + +impl FhirPathConstraintEvaluator { + pub fn new() -> Self { + Self::default() + } +} + +impl ConstraintEvaluator for FhirPathConstraintEvaluator { + fn evaluate_all( + &self, + resource: &Value, + version: FhirVersion, + constraints: &[DeferredConstraint<'_>], + ) -> Vec { + let typed = match parse_typed(resource, version) { + Ok(t) => t, + Err(e) => { + // Structural errors are already on the report when the + // typed parse fails; constraints simply cannot run. + let detail = format!("resource does not parse into the typed model: {e}"); + return constraints + .iter() + .map(|_| ConstraintOutcome::NotEvaluable(detail.clone())) + .collect(); + } + }; + let context = EvaluationContext::new(vec![typed]); + + constraints + .iter() + .map(|c| { + let combined = combine(c.path, c.expression); + self.evaluate_one(&combined, &context) + }) + .collect() + } +} + +impl FhirPathConstraintEvaluator { + fn evaluate_one(&self, expression: &str, context: &EvaluationContext) -> ConstraintOutcome { + // Parse (or reuse) the AST, then evaluate. + let parsed = { + let mut cache = self.asts.lock().expect("ast cache lock"); + match cache.get(expression) { + Some(ast) => std::sync::Arc::clone(ast), + None => match helios_fhirpath::parse_expression(expression) { + Ok(ast) => { + let ast = std::sync::Arc::new(ast); + cache.insert(expression.to_string(), std::sync::Arc::clone(&ast)); + ast + } + Err(e) => return ConstraintOutcome::NotEvaluable(format!("parse error: {e}")), + }, + } + }; + + match helios_fhirpath::evaluator::evaluate(&parsed, context, None) { + Ok(result) => coerce(result), + Err(e) => ConstraintOutcome::NotEvaluable(format!("evaluation error: {e}")), + } + } +} + +/// Build the combined focus+invariant expression from a dotted validator +/// path (`Patient.contact.0` → `contact[0].all()`). +fn combine(path: &str, expression: &str) -> String { + let mut relative = String::new(); + for segment in path.split('.').skip(1) { + if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() { + relative.push('['); + relative.push_str(segment); + relative.push(']'); + } else { + if !relative.is_empty() { + relative.push('.'); + } + relative.push_str(segment); + } + } + if relative.is_empty() { + expression.to_string() + } else { + format!("{relative}.all({expression})") + } +} + +/// FHIRPath 5.1.1 boolean coercion with FHIR invariant semantics. +fn coerce(result: EvaluationResult) -> ConstraintOutcome { + match result { + EvaluationResult::Empty => ConstraintOutcome::Passed, + EvaluationResult::Boolean(b, ..) => { + if b { + ConstraintOutcome::Passed + } else { + ConstraintOutcome::Failed + } + } + EvaluationResult::Collection { items, .. } => match items.as_slice() { + [] => ConstraintOutcome::Passed, + [EvaluationResult::Boolean(b, ..)] => { + if *b { + ConstraintOutcome::Passed + } else { + ConstraintOutcome::Failed + } + } + [_] => ConstraintOutcome::NotEvaluable("single non-boolean result".to_string()), + _ => ConstraintOutcome::NotEvaluable("multiple results".to_string()), + }, + _ => ConstraintOutcome::NotEvaluable("single non-boolean result".to_string()), + } +} + +/// Parse raw JSON into the version's typed model (`crates/sof` pattern). +fn parse_typed(resource: &Value, version: FhirVersion) -> Result { + match version { + #[cfg(feature = "R4")] + FhirVersion::R4 => serde_json::from_value::(resource.clone()) + .map(|r| FhirResource::R4(Box::new(r))) + .map_err(|e| e.to_string()), + #[cfg(feature = "R4B")] + FhirVersion::R4B => serde_json::from_value::(resource.clone()) + .map(|r| FhirResource::R4B(Box::new(r))) + .map_err(|e| e.to_string()), + #[cfg(feature = "R5")] + FhirVersion::R5 => serde_json::from_value::(resource.clone()) + .map(|r| FhirResource::R5(Box::new(r))) + .map_err(|e| e.to_string()), + #[cfg(feature = "R6")] + FhirVersion::R6 => serde_json::from_value::(resource.clone()) + .map(|r| FhirResource::R6(Box::new(r))) + .map_err(|e| e.to_string()), + #[allow(unreachable_patterns)] + other => Err(format!("FHIR version {other:?} not enabled in helios-fhir-validator")), + } +} + +#[cfg(test)] +mod tests { + use super::combine; + + #[test] + fn combines_paths_into_focused_expressions() { + assert_eq!(combine("Patient", "name.exists()"), "name.exists()"); + assert_eq!( + combine("Patient.contact.0", "name.exists() or telecom.exists()"), + "contact[0].all(name.exists() or telecom.exists())" + ); + assert_eq!(combine("Patient.name.2.given", "length() < 10"), "name[2].given.all(length() < 10)"); + } +} diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs new file mode 100644 index 000000000..d37d4d206 --- /dev/null +++ b/crates/fhir-validator/src/lib.rs @@ -0,0 +1,83 @@ +//! # helios-fhir-validator +//! +//! FHIR resource validation for the Helios FHIR server, built on the +//! [FHIR Schema](https://fhir-schema.github.io/fhir-schema/) approach: a +//! JSON-Schema-like, differential-by-design compiled form of FHIR +//! StructureDefinitions, validated via **cooperative schema sets** rather +//! than snapshot flattening. +//! +//! ## Quick start +//! +//! ``` +//! use helios_fhir_validator::{FhirSchema, SchemaRegistry, ValidationOptions, Validator}; +//! use std::sync::Arc; +//! +//! let mut registry = SchemaRegistry::new(); +//! registry.insert_named( +//! "string", +//! serde_json::from_str::(r#"{ "kind": "primitive-type" }"#).unwrap(), +//! ); +//! registry.insert_named( +//! "Patient", +//! serde_json::from_str::( +//! r#"{ "elements": { "resourceType": {"type": "string"}, "status": {"type": "string"} } }"#, +//! ) +//! .unwrap(), +//! ); +//! +//! let validator = Validator::new(Arc::new(registry)); +//! let resource = serde_json::json!({ "resourceType": "Patient", "status": "active" }); +//! let outcome = validator.validate_sync(&resource, &ValidationOptions::default()); +//! 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/`. +//! +//! ## 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). +//! - `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. +//! - 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. +//! - 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. + +pub mod converter; +pub mod effects; +pub mod engine; +pub mod packs; +pub mod resolver; +pub mod schema; + +#[cfg(feature = "fhirpath")] +pub mod fhirpath_effects; + +pub use effects::{ + CodedValue, ConstraintEvaluator, ConstraintOutcome, Deferred, DeferredConstraint, + EffectHandlers, TerminologyError, TerminologyProvider, +}; +pub use engine::{ + dotted_to_fhirpath, ErrorKind, Severity, SyncOutcome, UnknownProfilePolicy, ValidationError, + ValidationOptions, Validator, +}; +pub use resolver::{CompositeResolver, SchemaRegistry, SchemaResolver}; +pub use schema::{Binding, Constraint, FhirSchema, Match, Slice, Slicing}; diff --git a/crates/fhir-validator/src/packs.rs b/crates/fhir-validator/src/packs.rs new file mode 100644 index 000000000..a1b8b9288 --- /dev/null +++ b/crates/fhir-validator/src/packs.rs @@ -0,0 +1,81 @@ +//! Embedded core schema packs. +//! +//! One gzipped JSON array of FHIR Schemas per enabled FHIR version, +//! generated by the `generate-schema-packs` binary from the spec bundles in +//! `crates/fhir-gen/resources/` and committed under `packs/` (the same +//! committed-generated-artifact workflow as the fhir-gen models). +//! +//! Registries are built lazily, once per process per version. + +use crate::resolver::SchemaRegistry; +use crate::schema::FhirSchema; +use flate2::read::GzDecoder; +use helios_fhir::FhirVersion; +use std::io::Read; +use std::sync::{Arc, OnceLock}; + +#[cfg(feature = "R4")] +static R4_PACK: &[u8] = include_bytes!("../packs/fhir_schemas_r4.json.gz"); +#[cfg(feature = "R4B")] +static R4B_PACK: &[u8] = include_bytes!("../packs/fhir_schemas_r4b.json.gz"); +#[cfg(feature = "R5")] +static R5_PACK: &[u8] = include_bytes!("../packs/fhir_schemas_r5.json.gz"); +#[cfg(feature = "R6")] +static R6_PACK: &[u8] = include_bytes!("../packs/fhir_schemas_r6.json.gz"); + +/// The embedded core registry for a FHIR version. Parsed on first use; +/// subsequent calls return the shared instance. +pub fn core_registry(version: FhirVersion) -> Arc { + match version { + #[cfg(feature = "R4")] + FhirVersion::R4 => { + static REG: OnceLock> = OnceLock::new(); + Arc::clone(REG.get_or_init(|| Arc::new(load_pack(R4_PACK)))) + } + #[cfg(feature = "R4B")] + FhirVersion::R4B => { + static REG: OnceLock> = OnceLock::new(); + Arc::clone(REG.get_or_init(|| Arc::new(load_pack(R4B_PACK)))) + } + #[cfg(feature = "R5")] + FhirVersion::R5 => { + static REG: OnceLock> = OnceLock::new(); + Arc::clone(REG.get_or_init(|| Arc::new(load_pack(R5_PACK)))) + } + #[cfg(feature = "R6")] + FhirVersion::R6 => { + static REG: OnceLock> = OnceLock::new(); + Arc::clone(REG.get_or_init(|| Arc::new(load_pack(R6_PACK)))) + } + // Workspace feature unification can add FhirVersion variants beyond + // the packs this crate was built with. + #[allow(unreachable_patterns)] + other => panic!( + "no schema pack embedded for FHIR version {other:?} — enable the matching \ + helios-fhir-validator feature" + ), + } +} + +/// Decompress and index a pack. +/// +/// Insertion is two-pass — profiles (`derivation: constraint`) first, core +/// types and resources second — so that when a profile's `name` collides +/// with a core type name, the core schema wins the name alias. Canonical +/// URLs are unique and unaffected. +fn load_pack(bytes: &[u8]) -> SchemaRegistry { + let mut decoder = GzDecoder::new(bytes); + let mut json = Vec::new(); + decoder.read_to_end(&mut json).expect("embedded pack decompresses"); + let schemas: Vec = + serde_json::from_slice(&json).expect("embedded pack parses"); + + let mut registry = SchemaRegistry::new(); + let (profiles, core): (Vec<_>, Vec<_>) = schemas + .into_iter() + .partition(|s| s.derivation.as_deref() == Some("constraint")); + for schema in profiles.into_iter().chain(core) { + registry.insert(schema); + } + registry +} diff --git a/crates/fhir-validator/src/resolver.rs b/crates/fhir-validator/src/resolver.rs new file mode 100644 index 000000000..04fc0bcbb --- /dev/null +++ b/crates/fhir-validator/src/resolver.rs @@ -0,0 +1,157 @@ +//! Schema resolution: how the engine turns a name or canonical URL into a +//! [`FhirSchema`]. +//! +//! Mirrors the SearchParameter registry pattern in `crates/fhir/src/search/`: +//! an `Arc`-backed in-memory registry, composable so tenant-uploaded profiles +//! can overlay the embedded core packs. + +use crate::schema::FhirSchema; +use std::collections::HashMap; +use std::sync::Arc; + +/// Resolves a schema reference — a bare name (`"Patient"`) or a canonical URL +/// (`"http://hl7.org/fhir/StructureDefinition/Patient"`) — to a schema. +pub trait SchemaResolver: Send + Sync { + /// Returns the schema, or `None` if the reference is unknown. + fn resolve(&self, reference: &str) -> Option>; +} + +/// In-memory schema registry. +/// +/// Schemas are indexed under every identity they carry: the explicit key they +/// were inserted with, their `url`, and their `name`. All aliases map to the +/// same `Arc`, so identity-based deduplication in the engine treats them as +/// one schema. +#[derive(Debug, Default)] +pub struct SchemaRegistry { + map: HashMap>, +} + +impl SchemaRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Number of distinct keys (aliases included). + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Insert a schema under an explicit key, plus its `url`/`name` aliases. + /// + /// The explicit key wins on collision (later inserts overwrite earlier + /// ones for the same key — last write wins, like the JS fixture maps). + pub fn insert_named(&mut self, key: impl Into, schema: FhirSchema) { + let schema = Arc::new(schema); + let key = key.into(); + if let Some(url) = schema.url.clone() + && url != key { + self.map.insert(url, Arc::clone(&schema)); + } + if let Some(name) = schema.name.clone() + && name != key { + self.map.insert(name, Arc::clone(&schema)); + } + self.map.insert(key, schema); + } + + /// Insert a schema under its own `url` and `name`. + /// + /// Returns `false` (and stores nothing) if the schema carries neither. + pub fn insert(&mut self, schema: FhirSchema) -> bool { + match (schema.url.clone(), schema.name.clone()) { + (Some(url), _) => { + self.insert_named(url, schema); + true + } + (None, Some(name)) => { + self.insert_named(name, schema); + true + } + (None, None) => false, + } + } + + /// Build a registry from `(key, schema)` pairs. + pub fn from_named(iter: impl IntoIterator) -> Self { + let mut reg = Self::new(); + for (k, s) in iter { + reg.insert_named(k, s); + } + reg + } +} + +impl SchemaResolver for SchemaRegistry { + fn resolve(&self, reference: &str) -> Option> { + self.map.get(reference).cloned() + } +} + +/// Layered resolver: earlier layers win. Used to overlay tenant-uploaded +/// profiles on top of an embedded core pack. +#[derive(Clone, Default)] +pub struct CompositeResolver { + layers: Vec>, +} + +impl CompositeResolver { + pub fn new(layers: Vec>) -> Self { + Self { layers } + } + + /// Add a layer with lower precedence than all existing layers. + pub fn push(&mut self, layer: Arc) { + self.layers.push(layer); + } +} + +impl SchemaResolver for CompositeResolver { + fn resolve(&self, reference: &str) -> Option> { + self.layers.iter().find_map(|l| l.resolve(reference)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> FhirSchema { + FhirSchema { + name: Some(name.to_string()), + url: Some(format!("http://example.org/{name}")), + ..Default::default() + } + } + + #[test] + fn resolves_by_key_url_and_name() { + let mut reg = SchemaRegistry::new(); + reg.insert_named("PatKey", named("Patient")); + let by_key = reg.resolve("PatKey").unwrap(); + let by_url = reg.resolve("http://example.org/Patient").unwrap(); + let by_name = reg.resolve("Patient").unwrap(); + assert!(Arc::ptr_eq(&by_key, &by_url)); + assert!(Arc::ptr_eq(&by_key, &by_name)); + assert!(reg.resolve("nope").is_none()); + } + + #[test] + fn composite_earlier_layer_wins() { + let mut base = SchemaRegistry::new(); + base.insert_named("Patient", FhirSchema { kind: Some("resource".into()), ..Default::default() }); + let mut overlay = SchemaRegistry::new(); + overlay.insert_named("Patient", FhirSchema { kind: Some("constraint".into()), ..Default::default() }); + + let composite = + CompositeResolver::new(vec![Arc::new(overlay), Arc::new(base)]); + assert_eq!( + composite.resolve("Patient").unwrap().kind.as_deref(), + Some("constraint") + ); + } +} diff --git a/crates/fhir-validator/src/schema.rs b/crates/fhir-validator/src/schema.rs new file mode 100644 index 000000000..eba382648 --- /dev/null +++ b/crates/fhir-validator/src/schema.rs @@ -0,0 +1,313 @@ +//! The FHIR Schema data model. +//! +//! FHIR Schema is a JSON-Schema-like, differential-by-design representation of +//! FHIR StructureDefinitions (see ). +//! One struct serves both the schema level and the element level — the two are +//! the same shape in the format (confirmed by the upstream conformance suite). +//! +//! Everything is optional: a schema on disk is a differential fragment, and +//! the "complete picture" only exists transiently as a cooperative schema set +//! during validation (see [`crate::engine`]). +//! +//! Deserialization is tolerant: unknown keys are ignored (mirroring the +//! reference validator, which logs and skips unrecognized keywords). + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; + +/// Schema `kind` values with meaning to the validation engine. +/// +/// Kept as plain string constants (rather than an enum) because fixtures and +/// converted packs carry open-ended kinds (`resource`, `complex-type`, +/// `logical`, `extension`, ...) and only `primitive-type` alters behavior: +/// it stops complex-`type` expansion during schema-set assembly. +pub mod kind { + pub const PRIMITIVE_TYPE: &str = "primitive-type"; + pub const RESOURCE: &str = "resource"; + pub const COMPLEX_TYPE: &str = "complex-type"; + pub const EXTENSION: &str = "extension"; +} + +/// A FHIR Schema — used both as a top-level (named) schema and as an element +/// schema nested under [`FhirSchema::elements`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FhirSchema { + // ------------------------------------------------------------------ + // Identity (top-level schemas) + // ------------------------------------------------------------------ + /// Canonical URL this schema is published under. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Machine-readable name (also usable as a resolution alias). + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Schema this one layers onto; resolved into the same cooperative set. + #[serde(skip_serializing_if = "Option::is_none")] + pub base: Option, + /// Schema kind — see [`kind`]. Only `primitive-type` alters assembly. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// `specialization` (custom resources) or `constraint` (profiles). + #[serde(skip_serializing_if = "Option::is_none")] + pub derivation: Option, + /// The type this schema (or element) constrains. On an element, naming a + /// complex type pulls that type's schema into the set; the special value + /// `Resource` resolves dynamically from the data's `resourceType`. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, + + // ------------------------------------------------------------------ + // Shape + // ------------------------------------------------------------------ + /// Element is a collection: only JSON arrays are accepted. Array-ness is + /// decided cooperatively — true if *any* layer says `array: true`. + #[serde(skip_serializing_if = "Option::is_none")] + pub array: Option, + /// Element is scalar: JSON arrays are rejected. Mutually exclusive with + /// `array` within one schema (layers may still disagree; array wins). + #[serde(skip_serializing_if = "Option::is_none")] + pub scalar: Option, + /// Minimum number of items (arrays only). + #[serde(skip_serializing_if = "Option::is_none")] + pub min: Option, + /// Maximum number of items (arrays only). + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, + + // ------------------------------------------------------------------ + // Structure + // ------------------------------------------------------------------ + /// Nested element schemas, keyed by element name. Order is preserved — + /// deterministic error emission is part of the conformance contract. + /// Values are `Arc` so cooperative sets can share them without cloning. + #[serde(skip_serializing_if = "Option::is_none")] + pub elements: Option>>, + /// Child keys that must be present on the node. + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + /// Child keys that must be absent from the node. + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded: Option>, + /// Reference to another element's schema by path, for recursive + /// structures (e.g. `Questionnaire.item`): + /// `["Questionnaire", "elements", "item"]`. + #[serde(skip_serializing_if = "Option::is_none")] + pub element_reference: Option>, + + // ------------------------------------------------------------------ + // Choice types (value[x]) + // ------------------------------------------------------------------ + /// On a choice-group declarer: the allowed concrete branch names. + #[serde(skip_serializing_if = "Option::is_none")] + pub choices: Option>, + /// On a concrete branch: the name of the choice group it belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub choice_of: Option, + + // ------------------------------------------------------------------ + // Value constraints + // ------------------------------------------------------------------ + /// Value must be deeply equal to this constant. + #[serde(skip_serializing_if = "Option::is_none")] + pub fixed: Option, + /// Value must partially match this constant (pattern keys present and + /// equal; extra data keys permitted). + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + /// Terminology binding. Only `required`-strength bindings are enforced. + #[serde(skip_serializing_if = "Option::is_none")] + pub binding: Option, + /// FHIRPath invariants, keyed by constraint id. + #[serde(skip_serializing_if = "Option::is_none")] + pub constraints: Option>, + /// Allowed reference target types (for Reference/canonical elements). + #[serde(skip_serializing_if = "Option::is_none")] + pub refers: Option>, + + // ------------------------------------------------------------------ + // Slicing + // ------------------------------------------------------------------ + /// Array slicing definition. + #[serde(skip_serializing_if = "Option::is_none")] + pub slicing: Option, + /// Extension sugar: named extensions keyed by slice name, compiled into + /// `slicing` on the `extension` element (matched by `{url}`) at + /// validation time. Entry values reuse [`FhirSchema`] (they carry `url`, + /// `min`, `max`, plus any extra element constraints). + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option>>, + + // ------------------------------------------------------------------ + // Informational (carried, never enforced) + // ------------------------------------------------------------------ + /// Mirrors `ElementDefinition.isModifier`. + #[serde(skip_serializing_if = "Option::is_none")] + pub modifier: Option, + /// Mirrors `ElementDefinition.mustSupport`. + #[serde(skip_serializing_if = "Option::is_none")] + pub must_support: Option, + /// Mirrors `ElementDefinition.isSummary`. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + + // ------------------------------------------------------------------ + // Helios extension + // ------------------------------------------------------------------ + /// Primitive value regex, carried from the FHIR spec's primitive type + /// StructureDefinitions by our converter. Not part of upstream FHIR + /// Schema; absent from the upstream conformance fixtures. + #[serde(skip_serializing_if = "Option::is_none")] + pub regex: Option, +} + +impl FhirSchema { + /// Whether this schema is a primitive-type leaf: registered in the set + /// but never expanded as a complex type. + pub fn is_primitive(&self) -> bool { + self.kind.as_deref() == Some(kind::PRIMITIVE_TYPE) + } +} + +/// Terminology binding: the ValueSet a coded value must belong to. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Binding { + /// Canonical URL of the bound ValueSet. + pub value_set: String, + /// FHIR binding strength (`required`, `extensible`, `preferred`, + /// `example`). Only `required` is validated. + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, +} + +/// A FHIRPath invariant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Constraint { + /// FHIRPath expression that must evaluate truthy on the node. + pub expression: String, + /// `error` | `warning` | `guideline`. Only `error` fails validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, + /// Human-readable description, used in the emitted message. + #[serde(skip_serializing_if = "Option::is_none")] + pub human: Option, +} + +/// Array slicing: partitions a repeating element into named slices. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Slicing { + /// Named slices. Order preserved for deterministic sweep/error order. + pub slices: IndexMap, + /// `open` (default) | `closed` | `openAtEnd`. + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option, + /// When true, matched items must appear in slice `order`. + #[serde(skip_serializing_if = "Option::is_none")] + pub ordered: Option, +} + +/// One slice of a sliced array. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Slice { + /// How items are matched into this slice. Optional: constraining slices + /// (`sliceIsConstraining`) reference a parent slice instead. + #[serde(rename = "match", skip_serializing_if = "Option::is_none")] + pub match_: Option, + /// Minimum number of matched items across the whole array. + #[serde(skip_serializing_if = "Option::is_none")] + pub min: Option, + /// Maximum number of matched items across the whole array. + /// `Some(0)` means the slice is prohibited. + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, + /// Position for `ordered` slicing. + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Name of the parent slice being re-sliced. + #[serde(skip_serializing_if = "Option::is_none")] + pub reslice: Option, + /// Further constrains a slice of the same name from a parent schema. + #[serde(skip_serializing_if = "Option::is_none")] + pub slice_is_constraining: Option, + /// Element schema applied cooperatively to items matched into the slice. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option>, +} + +/// A slice match rule. +#[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). + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, + /// The pattern / binding / profile / type payload. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + /// Match against the resolved reference target instead of the reference + /// element itself. + #[serde(rename = "resolve-ref", skip_serializing_if = "Option::is_none")] + pub resolve_ref: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_primitive_schema() { + let s: FhirSchema = serde_json::from_str(r#"{ "kind": "primitive-type" }"#).unwrap(); + assert!(s.is_primitive()); + assert!(s.elements.is_none()); + } + + #[test] + fn parses_elements_preserving_order() { + let s: FhirSchema = serde_json::from_str( + r#"{ "elements": { "z": {"type": "string"}, "a": {"type": "string"} } }"#, + ) + .unwrap(); + let keys: Vec<&str> = s.elements.as_ref().unwrap().keys().map(|k| k.as_str()).collect(); + assert_eq!(keys, vec!["z", "a"], "element declaration order must be preserved"); + } + + #[test] + fn tolerates_unknown_keys() { + let s: FhirSchema = + serde_json::from_str(r#"{ "type": "string", "somethingNew": 42 }"#).unwrap(); + assert_eq!(s.type_.as_deref(), Some("string")); + } + + #[test] + fn parses_slicing_shape() { + let s: FhirSchema = serde_json::from_str( + r#"{ + "slicing": { + "slices": { + "home": { + "match": { "type": "pattern", "value": { "use": "home" } }, + "min": 1, "max": 1, + "schema": { "required": ["city"] } + } + } + } + }"#, + ) + .unwrap(); + let slicing = s.slicing.unwrap(); + let home = &slicing.slices["home"]; + assert_eq!(home.min, Some(1)); + let m = home.match_.as_ref().unwrap(); + assert_eq!(m.type_.as_deref(), Some("pattern")); + assert_eq!(m.value.as_ref().unwrap()["use"], "home"); + assert_eq!( + home.schema.as_ref().unwrap().required.as_ref().unwrap(), + &vec!["city".to_string()] + ); + } +} diff --git a/crates/fhir-validator/tests/common/mod.rs b/crates/fhir-validator/tests/common/mod.rs new file mode 100644 index 000000000..0416bb4da --- /dev/null +++ b/crates/fhir-validator/tests/common/mod.rs @@ -0,0 +1,94 @@ +//! Shared fixture runner for the conformance-style suites. +//! +//! Fixture shape (upstream FHIR Schema contract, reused verbatim for the +//! Helios extended suite): +//! - the file's `schemas` map becomes the resolver, keyed by name/url +//! - each case validates `data` with the root schema auto-resolved from +//! `data.resourceType`, plus the case's extra `schemas` as profiles +//! - the emitted error list must equal the expected `errors` (or `[]` when +//! the key is absent) under exact ordered deep-equality +//! - `skip: true` cases are skipped; `focus: true` is ignored + +use helios_fhir_validator::{ + FhirSchema, SchemaRegistry, UnknownProfilePolicy, ValidationOptions, Validator, +}; +use serde::Deserialize; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Deserialize)] +struct FixtureFile { + #[allow(dead_code)] + desc: Option, + schemas: serde_json::Map, + tests: Vec, +} + +#[derive(Deserialize)] +struct FixtureCase { + desc: Option, + /// Extra profile schema names to layer on top of the auto-resolved root. + #[serde(default)] + schemas: Vec, + data: Value, + /// Absent ⇒ the case must validate clean. + errors: Option, + #[serde(default)] + skip: bool, +} + +/// Runs every case in one fixture file; panics with a per-case diff report on +/// any mismatch. +pub fn run_fixture_file(sub: &str, name: &str) { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(sub) + .join(name); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read fixture {}: {e}", path.display())); + let file: FixtureFile = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("cannot parse fixture {name}: {e}")); + + let mut registry = SchemaRegistry::new(); + for (key, def) in &file.schemas { + let schema: FhirSchema = serde_json::from_value(def.clone()) + .unwrap_or_else(|e| panic!("fixture {name}: schema '{key}' failed to parse: {e}")); + registry.insert_named(key.clone(), schema); + } + let validator = Validator::new(Arc::new(registry)); + + let mut failures: Vec = Vec::new(); + let mut ran = 0usize; + for (i, case) in file.tests.iter().enumerate() { + if case.skip { + continue; + } + ran += 1; + let desc = case.desc.clone().unwrap_or_else(|| format!("case #{i}")); + let opts = ValidationOptions { + profiles: case.schemas.clone(), + // Fixtures never carry meta.profile, and unresolvable references + // in fixtures are fixture bugs — surface them as errors. + use_meta_profiles: true, + unknown_profile: UnknownProfilePolicy::Error, + }; + let outcome = validator.validate_sync(&case.data, &opts); + let actual = serde_json::to_value(&outcome.errors).expect("errors serialize"); + let expected = case.errors.clone().unwrap_or_else(|| Value::Array(vec![])); + if actual != expected { + failures.push(format!( + " ✗ {desc}\n expected: {expected}\n actual: {actual}" + )); + } + } + + assert!( + failures.is_empty(), + "{name}: {}/{} cases failed:\n{}", + failures.len(), + ran, + failures.join("\n") + ); +} diff --git a/crates/fhir-validator/tests/conformance.rs b/crates/fhir-validator/tests/conformance.rs new file mode 100644 index 000000000..921ad8097 --- /dev/null +++ b/crates/fhir-validator/tests/conformance.rs @@ -0,0 +1,43 @@ +//! Runner for the vendored upstream FHIR Schema conformance suite +//! (`tests/fixtures/upstream/*.json` — see UPSTREAM.md there for provenance). + +mod common; + +fn run_upstream(name: &str) { + common::run_fixture_file("upstream", name) +} + +#[test] +fn upstream_1_elements() { + run_upstream("1_elements.json"); +} + +#[test] +fn upstream_2_base() { + run_upstream("2_base.json"); +} + +#[test] +fn upstream_3_choices() { + run_upstream("3_choices.json"); +} + +#[test] +fn upstream_4_required() { + run_upstream("4_required.json"); +} + +#[test] +fn upstream_5_slices() { + run_upstream("5_slices.json"); +} + +#[test] +fn upstream_6_extensions() { + run_upstream("6_extensions.json"); +} + +#[test] +fn upstream_7_bundles() { + run_upstream("7_bundles.json"); +} diff --git a/crates/fhir-validator/tests/converter_tests.rs b/crates/fhir-validator/tests/converter_tests.rs new file mode 100644 index 000000000..ae7384afd --- /dev/null +++ b/crates/fhir-validator/tests/converter_tests.rs @@ -0,0 +1,191 @@ +//! Golden tests for the StructureDefinition → FhirSchema converter. +//! +//! Each fixture in `tests/fixtures/structuredefinitions/` is a trimmed but +//! shape-faithful StructureDefinition; the expected FhirSchema is asserted +//! 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. + +use helios_fhir_validator::converter::convert; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; + +fn load_sd(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/structuredefinitions") + .join(name); + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap() +} + +fn convert_to_value(name: &str) -> Value { + let sd = load_sd(name); + let conversion = convert(&sd).unwrap_or_else(|e| panic!("{name}: conversion failed: {e}")); + serde_json::to_value(&conversion.schema).unwrap() +} + +#[test] +fn converts_snapshot_resource() { + let actual = convert_to_value("mini-patient.json"); + let expected = json!({ + "url": "http://hl7.org/fhir/StructureDefinition/Patient", + "name": "Patient", + "base": "http://hl7.org/fhir/StructureDefinition/DomainResource", + "kind": "resource", + "derivation": "specialization", + "type": "Patient", + "constraints": { + "dom-2": { + "expression": "contained.contained.empty()", + "severity": "error", + "human": "If the resource is contained in another resource, it SHALL NOT contain nested Resources" + }, + "dom-6": { + "expression": "text.`div`.exists()", + "severity": "warning", + "human": "A resource should have narrative for robust management" + } + }, + "elements": { + "resourceType": { "type": "code" }, + "gender": { + "type": "code", + "binding": { + "valueSet": "http://hl7.org/fhir/ValueSet/administrative-gender|4.0.1", + "strength": "required" + } + }, + "name": { "type": "HumanName", "array": true }, + "deceased": { "choices": ["deceasedBoolean", "deceasedDateTime"] }, + "deceasedBoolean": { "type": "boolean", "choiceOf": "deceased" }, + "deceasedDateTime": { "type": "dateTime", "choiceOf": "deceased" }, + "link": { + "type": "BackboneElement", + "array": true, + "required": ["other", "type"], + "constraints": { + "pat-1": { + "expression": "other.exists()", + "severity": "error", + "human": "Contact must have details" + } + }, + "elements": { + "other": { + "type": "Reference", + "refers": [ + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.org/fhir/StructureDefinition/RelatedPerson" + ] + }, + "type": { + "type": "code", + "binding": { + "valueSet": "http://hl7.org/fhir/ValueSet/link-type|4.0.1", + "strength": "required" + } + } + } + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn converts_differential_profile() { + let actual = convert_to_value("mini-profile.json"); + let expected = json!({ + "url": "http://example.org/StructureDefinition/mini-patient-profile", + "name": "MiniPatientProfile", + "base": "http://hl7.org/fhir/StructureDefinition/Patient", + "kind": "resource", + "derivation": "constraint", + "type": "Patient", + "required": ["birthDate", "identifier"], + "excluded": ["gender"], + "extensions": { + "race": { + "url": "http://example.org/StructureDefinition/race", + "min": 1, + "max": 1 + } + }, + "elements": { + "identifier": { + "array": true, + "min": 1, + "slicing": { + "slices": { + "mrn": { + "match": { + "type": "pattern", + "value": { "system": "http://example.org/mrn" } + }, + "min": 1, + "max": 1, + "schema": { + "required": ["system"], + "elements": { + "system": { "fixed": "http://example.org/mrn" } + } + } + } + }, + "rules": "open" + } + }, + "maritalStatus": { + "pattern": { "coding": [{ "system": "http://x" }] } + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn converts_content_reference() { + let actual = convert_to_value("mini-questionnaire.json"); + let expected = json!({ + "url": "http://hl7.org/fhir/StructureDefinition/Questionnaire", + "name": "Questionnaire", + "base": "http://hl7.org/fhir/StructureDefinition/DomainResource", + "kind": "resource", + "derivation": "specialization", + "type": "Questionnaire", + "elements": { + "resourceType": { "type": "code" }, + "item": { + "type": "BackboneElement", + "array": true, + "required": ["linkId"], + "elements": { + "linkId": { "type": "string" }, + "item": { + "array": true, + "elementReference": ["Questionnaire", "elements", "item"] + } + } + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn converts_primitive_with_regex() { + let actual = convert_to_value("primitive-string.json"); + let expected = json!({ + "url": "http://hl7.org/fhir/StructureDefinition/string", + "name": "string", + "kind": "primitive-type", + "derivation": "specialization", + "type": "string", + "regex": "[ \\r\\n\\t\\S]+" + }); + assert_eq!(actual, expected); +} diff --git a/crates/fhir-validator/tests/effects_tests.rs b/crates/fhir-validator/tests/effects_tests.rs new file mode 100644 index 000000000..986034563 --- /dev/null +++ b/crates/fhir-validator/tests/effects_tests.rs @@ -0,0 +1,296 @@ +//! Deferred-effects execution: stub-driven tests pinning constraint and +//! binding issue shapes, severity handling, suppression, dedup, and +//! fail-open/fail-closed terminology behavior. + +use async_trait::async_trait; +use helios_fhir::FhirVersion; +use helios_fhir_validator::{ + CodedValue, ConstraintEvaluator, ConstraintOutcome, DeferredConstraint, EffectHandlers, + FhirSchema, SchemaRegistry, Severity, TerminologyError, TerminologyProvider, + ValidationOptions, Validator, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn registry() -> SchemaRegistry { + let mut reg = SchemaRegistry::new(); + reg.insert_named( + "code", + serde_json::from_value::(json!({ "kind": "primitive-type", "type": "code" })) + .unwrap(), + ); + reg.insert_named( + "string", + serde_json::from_value::(json!({ "kind": "primitive-type", "type": "string" })) + .unwrap(), + ); + reg.insert_named( + "Patient", + serde_json::from_value::(json!({ + "constraints": { + "pat-x": { + "expression": "name.exists()", + "severity": "error", + "human": "must have a name" + }, + "pat-w": { + "expression": "telecom.exists()", + "severity": "warning", + "human": "should have telecom" + }, + "pat-g": { + "expression": "language.exists()", + "severity": "guideline" + } + }, + "elements": { + "resourceType": { "type": "string" }, + "name": { "type": "string" }, + "telecom": { "type": "string" }, + "gender": { + "type": "code", + "binding": { + "strength": "required", + "valueSet": "http://hl7.org/fhir/ValueSet/administrative-gender" + } + }, + "maritalStatus": { + "type": "CodeableConcept", + "binding": { + "strength": "extensible", + "valueSet": "http://hl7.org/fhir/ValueSet/marital-status" + } + } + } + })) + .unwrap(), + ); + reg.insert_named( + "CodeableConcept", + serde_json::from_value::(json!({ + "elements": { "text": { "type": "string" } } + })) + .unwrap(), + ); + reg +} + +/// Forces every selected constraint to the same outcome, recording calls. +struct ScriptedConstraints { + outcome: fn(&DeferredConstraint<'_>) -> ConstraintOutcome, + seen: Mutex>, +} + +impl ConstraintEvaluator for ScriptedConstraints { + fn evaluate_all( + &self, + _resource: &Value, + _version: FhirVersion, + constraints: &[DeferredConstraint<'_>], + ) -> Vec { + let mut seen = self.seen.lock().unwrap(); + constraints + .iter() + .map(|c| { + seen.push(c.id.to_string()); + (self.outcome)(c) + }) + .collect() + } +} + +/// Allow-list terminology stub. +struct AllowList(&'static [&'static str]); + +#[async_trait] +impl TerminologyProvider for AllowList { + async fn validate_code( + &self, + _value_set: &str, + coded: &CodedValue, + ) -> Result { + let code = match coded { + CodedValue::Code(c) => c.clone(), + CodedValue::Coding(v) => v["code"].as_str().unwrap_or_default().to_string(), + CodedValue::CodeableConcept(v) => { + v["coding"][0]["code"].as_str().unwrap_or_default().to_string() + } + }; + Ok(self.0.contains(&code.as_str())) + } +} + +/// Always-erroring terminology stub. +struct Down; + +#[async_trait] +impl TerminologyProvider for Down { + async fn validate_code( + &self, + _value_set: &str, + _coded: &CodedValue, + ) -> Result { + Err(TerminologyError("connection refused".to_string())) + } +} + +fn validator() -> Validator { + Validator::new(Arc::new(registry())) +} + +#[tokio::test] +async fn constraint_failure_shapes_and_severities() { + let evaluator = ScriptedConstraints { + outcome: |_| ConstraintOutcome::Failed, + seen: Mutex::new(Vec::new()), + }; + let handlers = + EffectHandlers { constraints: Some(&evaluator), ..Default::default() }; + let resource = json!({ "resourceType": "Patient" }); + let errors = validator() + .validate(&resource, FhirVersion::R4, &ValidationOptions::default(), &handlers) + .await; + + // guideline (pat-g) is never evaluated. + assert_eq!(evaluator.seen.lock().unwrap().as_slice(), &["pat-x", "pat-w"]); + + let as_json = serde_json::to_value(&errors).unwrap(); + assert_eq!( + as_json, + json!([ + { + "type": "fhirpath-constraint", + "path": "Patient", + "message": "FHIRPath constraint pat-x error: must have a name", + "constraint": "pat-x" + }, + { + "type": "fhirpath-constraint", + "path": "Patient", + "message": "FHIRPath constraint pat-w error: should have telecom", + "constraint": "pat-w" + } + ]) + ); + assert_eq!(errors[0].severity, Severity::Error); + assert_eq!(errors[1].severity, Severity::Warning); +} + +#[tokio::test] +async fn constraints_suppressed_and_not_evaluable() { + let evaluator = ScriptedConstraints { + outcome: |_| ConstraintOutcome::NotEvaluable("parse error: boom".to_string()), + seen: Mutex::new(Vec::new()), + }; + let suppress = vec!["pat-w".to_string()]; + let handlers = EffectHandlers { + constraints: Some(&evaluator), + suppress_constraints: &suppress, + ..Default::default() + }; + let resource = json!({ "resourceType": "Patient" }); + let errors = validator() + .validate(&resource, FhirVersion::R4, &ValidationOptions::default(), &handlers) + .await; + + assert_eq!(evaluator.seen.lock().unwrap().as_slice(), &["pat-x"]); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].severity, Severity::Warning, "not-evaluable is a warning"); + assert_eq!( + errors[0].message, + "FHIRPath constraint pat-x could not be evaluated: parse error: boom" + ); +} + +#[tokio::test] +async fn required_binding_pass_and_fail() { + let allow = AllowList(&["male", "female"]); + let handlers = EffectHandlers { terminology: Some(&allow), ..Default::default() }; + + let ok = json!({ "resourceType": "Patient", "gender": "male" }); + let errors = validator() + .validate(&ok, FhirVersion::R4, &ValidationOptions::default(), &handlers) + .await; + assert_eq!(errors, vec![]); + + let bad = json!({ "resourceType": "Patient", "gender": "zzz" }); + let errors = validator() + .validate(&bad, FhirVersion::R4, &ValidationOptions::default(), &handlers) + .await; + let as_json = serde_json::to_value(&errors).unwrap(); + assert_eq!( + as_json, + json!([{ + "type": "terminology-binding", + "path": "Patient.gender", + "message": "Provided coded value 'zzz' does not pass validation against the following valueset: 'http://hl7.org/fhir/ValueSet/administrative-gender'", + "binding": { + "valueSet": "http://hl7.org/fhir/ValueSet/administrative-gender", + "strength": "required" + } + }]) + ); +} + +#[tokio::test] +async fn non_required_bindings_are_not_checked() { + let allow = AllowList(&[]); // everything would fail if checked + let handlers = EffectHandlers { terminology: Some(&allow), ..Default::default() }; + let resource = json!({ + "resourceType": "Patient", + "maritalStatus": { "text": "married" } + }); + let errors = validator() + .validate(&resource, FhirVersion::R4, &ValidationOptions::default(), &handlers) + .await; + assert_eq!(errors, vec![], "extensible bindings must not be enforced"); +} + +#[tokio::test] +async fn terminology_outage_fail_open_and_closed() { + let down = Down; + let resource = json!({ "resourceType": "Patient", "gender": "male" }); + + let open = EffectHandlers { terminology: Some(&down), ..Default::default() }; + let errors = validator() + .validate(&resource, FhirVersion::R4, &ValidationOptions::default(), &open) + .await; + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].severity, Severity::Warning, "fail-open surfaces a warning"); + assert!(errors[0].message.contains("could not be performed")); + + let closed = EffectHandlers { + terminology: Some(&down), + terminology_fail_closed: true, + ..Default::default() + }; + let errors = validator() + .validate(&resource, FhirVersion::R4, &ValidationOptions::default(), &closed) + .await; + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].severity, Severity::Error, "fail-closed escalates to an error"); +} + +#[tokio::test] +async fn no_handlers_means_structural_only() { + let resource = json!({ "resourceType": "Patient", "gender": "zzz", "bogus": 1 }); + let errors = validator() + .validate( + &resource, + FhirVersion::R4, + &ValidationOptions::default(), + &EffectHandlers::default(), + ) + .await; + // Only the structural issue; deferred constraint/binding work is inert + // without handlers. + let as_json = serde_json::to_value(&errors).unwrap(); + assert_eq!( + as_json, + json!([{ + "type": "unknown-element", + "path": "Patient.bogus", + "message": "bogus is unknown" + }]) + ); +} diff --git a/crates/fhir-validator/tests/extended.rs b/crates/fhir-validator/tests/extended.rs new file mode 100644 index 000000000..390091a54 --- /dev/null +++ b/crates/fhir-validator/tests/extended.rs @@ -0,0 +1,55 @@ +//! Runner for the Helios extended fixture suite +//! (`tests/fixtures/extended/*.json`). +//! +//! 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`), +//! `elementReference` recursion, and required-satisfied-by-choice-branch. +//! Same fixture format and exact-match contract as the upstream suite. + +mod common; + +fn run_extended(name: &str) { + common::run_fixture_file("extended", name) +} + +#[test] +fn extended_excluded() { + run_extended("excluded.json"); +} + +#[test] +fn extended_cardinality() { + run_extended("cardinality.json"); +} + +#[test] +fn extended_fixed_pattern() { + run_extended("fixed_pattern.json"); +} + +#[test] +fn extended_choice_required() { + run_extended("choice_required.json"); +} + +#[test] +fn extended_slicing_rules() { + run_extended("slicing_rules.json"); +} + +#[test] +fn extended_sidecars() { + run_extended("sidecars.json"); +} + +#[test] +fn extended_element_reference() { + run_extended("element_reference.json"); +} + +#[test] +fn extended_primitives() { + run_extended("primitives.json"); +} diff --git a/crates/fhir-validator/tests/fhirpath_constraint_tests.rs b/crates/fhir-validator/tests/fhirpath_constraint_tests.rs new file mode 100644 index 000000000..5edaf4695 --- /dev/null +++ b/crates/fhir-validator/tests/fhirpath_constraint_tests.rs @@ -0,0 +1,106 @@ +//! End-to-end FHIRPath invariant evaluation (feature `fhirpath`): real +//! spec constraints from the embedded R4 pack, evaluated by helios-fhirpath +//! through the deferred-effects pipeline. +//! +//! Run with: `cargo test -p helios-fhir-validator --features fhirpath` +//! +//! ## MSVC 14.44 linker caveat +//! +//! This binary links helios-fhirpath's full dependency tree and can trip the +//! MSVC 14.44 (`link.exe` 14.44.35207) `LNK1318: Unexpected PDB error; +//! LIMIT (12)` defect on Windows. If that happens, the same scenarios are +//! covered end-to-end by `crates/rest/tests/validate_operation_tests.rs` +//! (`validate_evaluates_real_fhirpath_invariants`), whose test binary links +//! fine; alternatively add `-C link-arg=/PDBPAGESIZE:8192` to the MSVC +//! target rustflags or use a non-affected MSVC toolchain. + +#![cfg(all(feature = "fhirpath", feature = "R4"))] + +use helios_fhir::FhirVersion; +use helios_fhir_validator::fhirpath_effects::FhirPathConstraintEvaluator; +use helios_fhir_validator::packs::core_registry; +use helios_fhir_validator::{EffectHandlers, ErrorKind, ValidationOptions, Validator}; +use serde_json::json; + +fn handlers(evaluator: &FhirPathConstraintEvaluator) -> EffectHandlers<'_> { + EffectHandlers { constraints: Some(evaluator), ..Default::default() } +} + +/// dom-6 (narrative present) is a warning-severity invariant that fires on +/// almost every test resource; suppress it like the server default does. +fn suppress() -> Vec { + vec!["dom-6".to_string()] +} + +#[tokio::test] +async fn real_pat1_invariant_fires_on_detail_less_contact() { + let validator = Validator::new(core_registry(FhirVersion::R4)); + let evaluator = FhirPathConstraintEvaluator::new(); + let suppress = suppress(); + let mut h = handlers(&evaluator); + h.suppress_constraints = &suppress; + + // pat-1: "SHALL at least contain a contact's details or a reference to + // an organization" — a contact with only a gender violates it. + let violating = json!({ + "resourceType": "Patient", + "contact": [{ "gender": "male" }] + }); + let errors = validator + .validate(&violating, FhirVersion::R4, &ValidationOptions::default(), &h) + .await; + let pat1: Vec<_> = errors + .iter() + .filter(|e| e.kind == ErrorKind::FhirpathConstraint && e.path == "Patient.contact.0") + .collect(); + assert_eq!( + pat1.len(), + 1, + "pat-1 must fire exactly once at the contact node; all issues: {}", + serde_json::to_string_pretty(&errors).unwrap() + ); + assert_eq!( + pat1[0].extra.get("constraint"), + Some(&json!("pat-1")), + "constraint id carried in extras" + ); + + let satisfied = json!({ + "resourceType": "Patient", + "contact": [{ "gender": "male", "name": { "family": "Smith" } }] + }); + let errors = validator + .validate(&satisfied, FhirVersion::R4, &ValidationOptions::default(), &h) + .await; + assert!( + !errors.iter().any(|e| e.kind == ErrorKind::FhirpathConstraint + && e.extra.get("constraint") == Some(&json!("pat-1"))), + "pat-1 must pass when the contact has a name; got: {}", + serde_json::to_string_pretty(&errors).unwrap() + ); +} + +#[tokio::test] +async fn absent_nodes_pass_invariants() { + // FHIR invariant semantics: a constraint over an absent element is + // vacuously true (empty → pass). + let validator = Validator::new(core_registry(FhirVersion::R4)); + let evaluator = FhirPathConstraintEvaluator::new(); + let suppress = suppress(); + let mut h = handlers(&evaluator); + h.suppress_constraints = &suppress; + + let minimal = json!({ "resourceType": "Patient" }); + let errors = validator + .validate(&minimal, FhirVersion::R4, &ValidationOptions::default(), &h) + .await; + let hard_failures: Vec<_> = errors + .iter() + .filter(|e| e.severity == helios_fhir_validator::Severity::Error) + .collect(); + assert!( + hard_failures.is_empty(), + "a minimal Patient must have no error-severity issues, got: {}", + serde_json::to_string_pretty(&errors).unwrap() + ); +} diff --git a/crates/fhir-validator/tests/fixtures/extended/cardinality.json b/crates/fhir-validator/tests/fixtures/extended/cardinality.json new file mode 100644 index 000000000..3f74ccad4 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/cardinality.json @@ -0,0 +1,40 @@ +{ + "desc": "numeric array cardinality — Helios extended", + "schemas": { + "string": { "kind": "primitive-type" }, + "Patient": { + "elements": { + "resourceType": { "type": "string" }, + "name": { + "array": true, + "min": 2, + "max": 3, + "elements": { "text": { "type": "string" } } + } + } + } + }, + "tests": [ + { + "desc": "within bounds", + "data": { "resourceType": "Patient", "name": [{ "text": "a" }, { "text": "b" }] } + }, + { + "desc": "below min", + "data": { "resourceType": "Patient", "name": [{ "text": "a" }] }, + "errors": [ + { "type": "min", "path": "Patient.name", "message": "expected 2 > 1" } + ] + }, + { + "desc": "above max", + "data": { + "resourceType": "Patient", + "name": [{ "text": "a" }, { "text": "b" }, { "text": "c" }, { "text": "d" }] + }, + "errors": [ + { "type": "max", "path": "Patient.name", "message": "expected 3 < 4" } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/choice_required.json b/crates/fhir-validator/tests/fixtures/extended/choice_required.json new file mode 100644 index 000000000..7656d9d8d --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/choice_required.json @@ -0,0 +1,33 @@ +{ + "desc": "required satisfied by any declared choice branch — Helios extended (converter maps min>=1 on foo[x] to required:[foo])", + "schemas": { + "string": { "kind": "primitive-type" }, + "boolean": { "kind": "primitive-type" }, + "Observation": { + "required": ["value"], + "elements": { + "resourceType": { "type": "string" }, + "value": { "choices": ["valueString", "valueBoolean"] }, + "valueString": { "choiceOf": "value", "type": "string" }, + "valueBoolean": { "choiceOf": "value", "type": "boolean" } + } + } + }, + "tests": [ + { + "desc": "branch presence satisfies the group requirement", + "data": { "resourceType": "Observation", "valueString": "ok" } + }, + { + "desc": "other branch also satisfies", + "data": { "resourceType": "Observation", "valueBoolean": true } + }, + { + "desc": "missing entirely", + "data": { "resourceType": "Observation" }, + "errors": [ + { "type": "required", "path": "Observation.value", "message": "value is required" } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/element_reference.json b/crates/fhir-validator/tests/fixtures/extended/element_reference.json new file mode 100644 index 000000000..722fd005c --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/element_reference.json @@ -0,0 +1,84 @@ +{ + "desc": "elementReference recursion — Helios extended (Questionnaire.item pattern; absent from the reference validator)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Questionnaire": { + "elements": { + "resourceType": { "type": "string" }, + "item": { + "array": true, + "elements": { + "linkId": { "type": "string" }, + "type": { "type": "string" }, + "item": { + "array": true, + "elementReference": ["Questionnaire", "elements", "item"] + } + } + } + } + } + }, + "tests": [ + { + "desc": "nested items validate through the reference", + "data": { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "q1", + "type": "group", + "item": [{ "linkId": "q2", "type": "display" }] + } + ] + } + }, + { + "desc": "three levels deep", + "data": { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "q1", + "item": [ + { "linkId": "q2", "item": [{ "linkId": "q3", "type": "display" }] } + ] + } + ] + } + }, + { + "desc": "unknown key at depth is caught", + "data": { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "q1", + "item": [{ "item": [{ "bogus": true }] }] + } + ] + }, + "errors": [ + { + "type": "unknown-element", + "path": "Questionnaire.item.0.item.0.item.0.bogus", + "message": "bogus is unknown" + } + ] + }, + { + "desc": "nested item must still be an array", + "data": { + "resourceType": "Questionnaire", + "item": [{ "linkId": "q1", "item": { "linkId": "q2" } }] + }, + "errors": [ + { + "type": "not-array", + "path": "Questionnaire.item.0.item", + "message": "item is not array" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/excluded.json b/crates/fhir-validator/tests/fixtures/extended/excluded.json new file mode 100644 index 000000000..f9f93cdd8 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/excluded.json @@ -0,0 +1,54 @@ +{ + "desc": "excluded keyword — Helios extended (broken in the reference validator)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Patient": { + "elements": { + "resourceType": { "type": "string" }, + "gender": { "type": "string" }, + "birthDate": { "type": "string" } + } + }, + "NoGender": { + "base": "Patient", + "excluded": ["gender"], + "required": ["birthDate"] + } + }, + "tests": [ + { + "desc": "clean when excluded key absent", + "schemas": ["NoGender"], + "data": { "resourceType": "Patient", "birthDate": "2000-01-01" } + }, + { + "desc": "excluded key present", + "schemas": ["NoGender"], + "data": { "resourceType": "Patient", "gender": "male", "birthDate": "2000-01-01" }, + "errors": [ + { + "type": "excluded", + "path": "Patient.gender", + "message": "excluded property gender is present" + } + ] + }, + { + "desc": "excluded and required combine across layers", + "schemas": ["NoGender"], + "data": { "resourceType": "Patient", "gender": "other" }, + "errors": [ + { + "type": "required", + "path": "Patient.birthDate", + "message": "birthDate is required" + }, + { + "type": "excluded", + "path": "Patient.gender", + "message": "excluded property gender is present" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/fixed_pattern.json b/crates/fhir-validator/tests/fixtures/extended/fixed_pattern.json new file mode 100644 index 000000000..73d8a8eb9 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/fixed_pattern.json @@ -0,0 +1,54 @@ +{ + "desc": "fixed and pattern keywords — Helios extended (message wording is ours: scalars render bare, composites render as compact JSON)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Patient": { + "elements": { + "resourceType": { "type": "string" }, + "gender": { "type": "string", "fixed": "male" }, + "maritalStatus": { + "pattern": { "system": "http://x" }, + "elements": { + "text": { "type": "string" }, + "system": { "type": "string" } + } + } + } + } + }, + "tests": [ + { + "desc": "fixed satisfied", + "data": { "resourceType": "Patient", "gender": "male" } + }, + { + "desc": "fixed violated", + "data": { "resourceType": "Patient", "gender": "female" }, + "errors": [ + { + "type": "fixed-value", + "path": "Patient.gender", + "message": "Expected value to be exactly equal to 'fixed' pattern 'male', but got: 'female'" + } + ] + }, + { + "desc": "pattern satisfied with extra keys", + "data": { + "resourceType": "Patient", + "maritalStatus": { "system": "http://x", "text": "M" } + } + }, + { + "desc": "pattern violated", + "data": { "resourceType": "Patient", "maritalStatus": { "text": "M" } }, + "errors": [ + { + "type": "pattern-value", + "path": "Patient.maritalStatus", + "message": "Expected value to match 'pattern' '{\"system\":\"http://x\"}', but got: '{\"text\":\"M\"}'" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/primitives.json b/crates/fhir-validator/tests/fixtures/extended/primitives.json new file mode 100644 index 000000000..79ffa6f7f --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/primitives.json @@ -0,0 +1,89 @@ +{ + "desc": "primitive value validation — Helios extended (JSON type-class from the primitive's declared type, regex from the pack's `regex` extension; dead code upstream)", + "schemas": { + "string": { "kind": "primitive-type", "type": "string", "regex": "[ \\r\\n\\t\\S]+" }, + "boolean": { "kind": "primitive-type", "type": "boolean" }, + "integer": { "kind": "primitive-type", "type": "integer" }, + "decimal": { "kind": "primitive-type", "type": "decimal" }, + "date": { + "kind": "primitive-type", + "type": "date", + "regex": "([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?" + }, + "Patient": { + "elements": { + "resourceType": { "type": "string" }, + "active": { "type": "boolean" }, + "rank": { "type": "integer" }, + "score": { "type": "decimal" }, + "birthDate": { "type": "date" }, + "note": { "type": "string" } + } + } + }, + "tests": [ + { + "desc": "all primitive values well-typed", + "data": { + "resourceType": "Patient", + "active": true, + "rank": 3, + "score": 1.5, + "birthDate": "1974-12-25", + "note": "hello" + } + }, + { + "desc": "boolean given a string", + "data": { "resourceType": "Patient", "active": "true" }, + "errors": [ + { + "type": "primitive-value", + "path": "Patient.active", + "message": "expected boolean, got string" + } + ] + }, + { + "desc": "integer given a decimal number", + "data": { "resourceType": "Patient", "rank": 2.5 }, + "errors": [ + { + "type": "primitive-value", + "path": "Patient.rank", + "message": "expected integer, got number" + } + ] + }, + { + "desc": "string given a number", + "data": { "resourceType": "Patient", "note": 5 }, + "errors": [ + { + "type": "primitive-value", + "path": "Patient.note", + "message": "expected string, got number" + } + ] + }, + { + "desc": "date failing its regex", + "data": { "resourceType": "Patient", "birthDate": "25/12/1974" }, + "errors": [ + { + "type": "primitive-value", + "path": "Patient.birthDate", + "message": "value '25/12/1974' is not a valid date" + } + ] + }, + { + "desc": "partial date passes the anchored regex", + "data": { "resourceType": "Patient", "birthDate": "1974-12" } + }, + { + "desc": "decimal accepts integers too", + "data": { "resourceType": "Patient", "score": 2 } + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/sidecars.json b/crates/fhir-validator/tests/fixtures/extended/sidecars.json new file mode 100644 index 000000000..645a2533d --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/sidecars.json @@ -0,0 +1,120 @@ +{ + "desc": "primitive extension sidecars (_field) — Helios extended", + "schemas": { + "string": { "kind": "primitive-type" }, + "Extension": { + "elements": { + "url": { "type": "string" }, + "valueString": { "type": "string" } + } + }, + "Element": { + "elements": { + "id": { "type": "string" }, + "extension": { "array": true, "type": "Extension" } + } + }, + "Patient": { + "elements": { + "resourceType": { "type": "string" }, + "status": { "type": "string" }, + "given": { "type": "string", "array": true }, + "name": { + "elements": { "family": { "type": "string" } } + } + } + } + }, + "tests": [ + { + "desc": "scalar sidecar with id and extension", + "data": { + "resourceType": "Patient", + "status": "active", + "_status": { + "id": "s1", + "extension": [{ "url": "http://x", "valueString": "v" }] + } + } + }, + { + "desc": "orphan sidecar (value absent) is legal for a declared primitive", + "data": { "resourceType": "Patient", "_status": { "id": "s1" } } + }, + { + "desc": "unknown key inside the sidecar Element part", + "data": { "resourceType": "Patient", "_status": { "bogus": 1 } }, + "errors": [ + { + "type": "unknown-element", + "path": "Patient._status.bogus", + "message": "bogus is unknown" + } + ] + }, + { + "desc": "stray sidecar for an undeclared element", + "data": { "resourceType": "Patient", "_bogus": { "id": "x" } }, + "errors": [ + { + "type": "unknown-element", + "path": "Patient._bogus", + "message": "_bogus is unknown" + } + ] + }, + { + "desc": "sidecar on a complex element rejected", + "data": { + "resourceType": "Patient", + "name": { "family": "F" }, + "_name": { "id": "x" } + }, + "errors": [ + { + "type": "unknown-element", + "path": "Patient._name", + "message": "_name is unknown" + } + ] + }, + { + "desc": "array sidecar aligned with nulls on either side", + "data": { + "resourceType": "Patient", + "given": ["A", null], + "_given": [null, { "id": "g2" }] + } + }, + { + "desc": "array element demands array sidecar", + "data": { + "resourceType": "Patient", + "given": ["A"], + "_given": { "id": "g" } + }, + "errors": [ + { + "type": "not-array", + "path": "Patient._given", + "message": "_given is not array" + } + ] + }, + { + "desc": "scalar element demands scalar sidecar", + "data": { + "resourceType": "Patient", + "status": "active", + "_status": [{ "id": "s" }] + }, + "errors": [ + { + "type": "not-singular", + "path": "Patient._status", + "message": "_status is not singular" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/extended/slicing_rules.json b/crates/fhir-validator/tests/fixtures/extended/slicing_rules.json new file mode 100644 index 000000000..ba0ee07c5 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/extended/slicing_rules.json @@ -0,0 +1,227 @@ +{ + "desc": "slicing rules — Helios extended (closed/openAtEnd/ordered/@default and prohibited max:0 slices; the reference validator implements none of these)", + "schemas": { + "string": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": { "type": "string" }, + "address": { + "array": true, + "elements": { + "use": { "type": "string" }, + "city": { "type": "string" }, + "type": { "type": "string" } + } + } + } + }, + "ClosedProfile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "rules": "closed", + "slices": { + "home": { + "match": { "type": "pattern", "value": { "use": "home" } }, + "min": 1 + } + } + } + } + } + }, + "DefaultProfile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "rules": "closed", + "slices": { + "home": { + "match": { "type": "pattern", "value": { "use": "home" } }, + "min": 1 + }, + "@default": { + "schema": { "required": ["type"] } + } + } + } + } + } + }, + "OpenAtEndProfile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "rules": "openAtEnd", + "ordered": true, + "slices": { + "home": { + "order": 0, + "match": { "type": "pattern", "value": { "use": "home" } } + }, + "work": { + "order": 1, + "match": { "type": "pattern", "value": { "use": "work" } } + } + } + } + } + } + }, + "OrderedProfile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "ordered": true, + "slices": { + "home": { + "order": 0, + "match": { "type": "pattern", "value": { "use": "home" } } + }, + "work": { + "order": 1, + "match": { "type": "pattern", "value": { "use": "work" } } + } + } + } + } + } + }, + "ProhibitProfile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "slices": { + "temp": { + "max": 0, + "match": { "type": "pattern", "value": { "use": "temp" } } + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "closed: all items match", + "schemas": ["ClosedProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "home", "city": "X" }] + } + }, + { + "desc": "closed: unmatched item rejected", + "schemas": ["ClosedProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "work" }] + }, + "errors": [ + { + "type": "slice-unmatched", + "path": "Resource.address.1", + "message": "item does not match any slice in closed slicing" + } + ] + }, + { + "desc": "closed with @default: unmatched items absorbed and validated", + "schemas": ["DefaultProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "billing", "type": "postal" }] + } + }, + { + "desc": "closed with @default: absorbed item violates the default schema", + "schemas": ["DefaultProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "billing" }] + }, + "errors": [ + { + "type": "required", + "path": "Resource.address.1.type", + "message": "type is required" + } + ] + }, + { + "desc": "openAtEnd: unmatched items allowed at the end", + "schemas": ["OpenAtEndProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "work" }, { "use": "temp" }] + } + }, + { + "desc": "openAtEnd: unmatched item before matched items rejected", + "schemas": ["OpenAtEndProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "temp" }, { "use": "home" }, { "use": "work" }] + }, + "errors": [ + { + "type": "slice-unmatched", + "path": "Resource.address.0", + "message": "unmatched item must be at the end of the array (openAtEnd slicing)" + } + ] + }, + { + "desc": "ordered: items follow slice order", + "schemas": ["OrderedProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }, { "use": "home" }, { "use": "work" }] + } + }, + { + "desc": "ordered: out-of-order item rejected", + "schemas": ["OrderedProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "work" }, { "use": "home" }] + }, + "errors": [ + { + "type": "slice-order", + "path": "Resource.address.1", + "message": "slice 'home' is out of order (ordered slicing)" + } + ] + }, + { + "desc": "prohibited slice (max 0) absent", + "schemas": ["ProhibitProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "home" }] + } + }, + { + "desc": "prohibited slice (max 0) present — enforced despite the reference validator's falsy-max bug", + "schemas": ["ProhibitProfile"], + "data": { + "resourceType": "Resource", + "address": [{ "use": "temp" }] + }, + "errors": [ + { + "type": "slice-cardinality", + "path": "Resource.address", + "message": "Slice defines the following max cardinality: '0', actual cardinality: '1'" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-patient.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-patient.json new file mode 100644 index 000000000..e655516e7 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-patient.json @@ -0,0 +1,123 @@ +{ + "resourceType": "StructureDefinition", + "id": "Patient", + "url": "http://hl7.org/fhir/StructureDefinition/Patient", + "name": "Patient", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/DomainResource", + "derivation": "specialization", + "snapshot": { + "element": [ + { + "id": "Patient", + "path": "Patient", + "min": 0, + "max": "*", + "base": { "path": "Patient", "min": 0, "max": "*" }, + "constraint": [ + { + "key": "dom-2", + "severity": "error", + "human": "If the resource is contained in another resource, it SHALL NOT contain nested Resources", + "expression": "contained.contained.empty()" + }, + { + "key": "dom-6", + "severity": "warning", + "human": "A resource should have narrative for robust management", + "expression": "text.`div`.exists()" + } + ] + }, + { + "id": "Patient.gender", + "path": "Patient.gender", + "min": 0, + "max": "1", + "base": { "path": "Patient.gender", "min": 0, "max": "1" }, + "type": [{ "code": "code" }], + "binding": { + "strength": "required", + "valueSet": "http://hl7.org/fhir/ValueSet/administrative-gender|4.0.1" + }, + "constraint": [ + { + "key": "ele-1", + "severity": "error", + "human": "All FHIR elements must have a @value or children", + "expression": "hasValue() or (children().count() > id.count())" + } + ] + }, + { + "id": "Patient.name", + "path": "Patient.name", + "min": 0, + "max": "*", + "base": { "path": "Patient.name", "min": 0, "max": "*" }, + "type": [{ "code": "HumanName" }] + }, + { + "id": "Patient.deceased[x]", + "path": "Patient.deceased[x]", + "min": 0, + "max": "1", + "base": { "path": "Patient.deceased[x]", "min": 0, "max": "1" }, + "type": [{ "code": "boolean" }, { "code": "dateTime" }] + }, + { + "id": "Patient.link", + "path": "Patient.link", + "min": 0, + "max": "*", + "base": { "path": "Patient.link", "min": 0, "max": "*" }, + "type": [{ "code": "BackboneElement" }], + "constraint": [ + { + "key": "pat-1", + "severity": "error", + "human": "Contact must have details", + "expression": "other.exists()" + }, + { + "key": "ele-1", + "severity": "error", + "human": "All FHIR elements must have a @value or children", + "expression": "hasValue() or (children().count() > id.count())" + } + ] + }, + { + "id": "Patient.link.other", + "path": "Patient.link.other", + "min": 1, + "max": "1", + "base": { "path": "Patient.link.other", "min": 1, "max": "1" }, + "type": [ + { + "code": "Reference", + "targetProfile": [ + "http://hl7.org/fhir/StructureDefinition/Patient", + "http://hl7.org/fhir/StructureDefinition/RelatedPerson" + ] + } + ] + }, + { + "id": "Patient.link.type", + "path": "Patient.link.type", + "min": 1, + "max": "1", + "base": { "path": "Patient.link.type", "min": 1, "max": "1" }, + "type": [{ "code": "code" }], + "binding": { + "strength": "required", + "valueSet": "http://hl7.org/fhir/ValueSet/link-type|4.0.1" + } + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-profile.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-profile.json new file mode 100644 index 000000000..7ddbd9c4c --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-profile.json @@ -0,0 +1,60 @@ +{ + "resourceType": "StructureDefinition", + "id": "mini-patient-profile", + "url": "http://example.org/StructureDefinition/mini-patient-profile", + "name": "MiniPatientProfile", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Patient", "path": "Patient" }, + { "id": "Patient.birthDate", "path": "Patient.birthDate", "min": 1 }, + { "id": "Patient.gender", "path": "Patient.gender", "max": "0" }, + { + "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/mrn" + }, + { + "id": "Patient.maritalStatus", + "path": "Patient.maritalStatus", + "patternCodeableConcept": { "coding": [{ "system": "http://x" }] } + }, + { + "id": "Patient.extension:race", + "path": "Patient.extension", + "sliceName": "race", + "min": 1, + "max": "1", + "type": [ + { + "code": "Extension", + "profile": ["http://example.org/StructureDefinition/race"] + } + ] + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-questionnaire.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-questionnaire.json new file mode 100644 index 000000000..07b8c117c --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-questionnaire.json @@ -0,0 +1,47 @@ +{ + "resourceType": "StructureDefinition", + "id": "Questionnaire", + "url": "http://hl7.org/fhir/StructureDefinition/Questionnaire", + "name": "Questionnaire", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Questionnaire", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/DomainResource", + "derivation": "specialization", + "snapshot": { + "element": [ + { + "id": "Questionnaire", + "path": "Questionnaire", + "min": 0, + "max": "*", + "base": { "path": "Questionnaire", "min": 0, "max": "*" } + }, + { + "id": "Questionnaire.item", + "path": "Questionnaire.item", + "min": 0, + "max": "*", + "base": { "path": "Questionnaire.item", "min": 0, "max": "*" }, + "type": [{ "code": "BackboneElement" }] + }, + { + "id": "Questionnaire.item.linkId", + "path": "Questionnaire.item.linkId", + "min": 1, + "max": "1", + "base": { "path": "Questionnaire.item.linkId", "min": 1, "max": "1" }, + "type": [{ "code": "string" }] + }, + { + "id": "Questionnaire.item.item", + "path": "Questionnaire.item.item", + "min": 0, + "max": "*", + "base": { "path": "Questionnaire.item.item", "min": 0, "max": "*" }, + "contentReference": "#Questionnaire.item" + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/primitive-string.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/primitive-string.json new file mode 100644 index 000000000..4214b5960 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/primitive-string.json @@ -0,0 +1,46 @@ +{ + "resourceType": "StructureDefinition", + "id": "string", + "url": "http://hl7.org/fhir/StructureDefinition/string", + "name": "string", + "status": "active", + "kind": "primitive-type", + "abstract": false, + "type": "string", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Element", + "derivation": "specialization", + "snapshot": { + "element": [ + { + "id": "string", + "path": "string", + "min": 0, + "max": "*", + "base": { "path": "string", "min": 0, "max": "*" } + }, + { + "id": "string.value", + "path": "string.value", + "representation": ["xmlAttr"], + "min": 0, + "max": "1", + "base": { "path": "string.value", "min": 0, "max": "1" }, + "type": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type", + "valueUrl": "string" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/regex", + "valueString": "[ \\r\\n\\t\\S]+" + } + ], + "code": "http://hl7.org/fhirpath/System.String" + } + ] + } + ] + } +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/1_elements.json b/crates/fhir-validator/tests/fixtures/upstream/1_elements.json new file mode 100644 index 000000000..9bd5aec9f --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/1_elements.json @@ -0,0 +1,281 @@ +{ + "desc": "test elements and arrays", + "schemas": { + "string": { + "kind": "primitive-type" + }, + "HumanName": { + "elements": { + "given": { + "type": "string", + "array": true + }, + "family": {"type": "string"}, + "nested": { + "elements": { + "element": {"type": "string"} + } + } + } + }, + "Patient": { + "elements": { + "resourceType": {"type": "string"}, + "status": {"type": "string"}, + "multielement": { + "type": "string", + "array": true + }, + "multielementObject": { + "array": true, + "elements": { + "element": { + "type": "string" + } + } + }, + "name": { + "type": "HumanName" + }, + "nested": { + "elements": { + "element": { + "type": "string" + }, + "nestedTwice": { + "elements": { + "element": { + "type": "string" + } + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "basic element", + "data": { + "resourceType": "Patient", + "status": "active" + } + }, + { + "desc": "unknown element", + "data": { + "resourceType": "Patient", + "unknown": "value" + }, + "errors": [ + { + "message": "unknown is unknown", + "type": "unknown-element", + "path": "Patient.unknown" + } + ] + }, + { + "desc": "nested element", + "data": { + "resourceType": "Patient", + "nested": { + "element": "value" + } + } + }, + { + "desc": "nested unknown element", + "data": { + "resourceType": "Patient", + "nested": { + "unknown": "not-ok" + } + }, + "errors": [ + { + "message": "unknown is unknown", + "path": "Patient.nested.unknown", + "type": "unknown-element" + } + ] + }, + { + "desc": "nested twice element", + "data": { + "resourceType": "Patient", + "nested": { + "nestedTwice": { + "element": "ok" + } + } + } + }, + { + "desc": "nested twice unknown element", + "data": { + "resourceType": "Patient", + "nested": { + "nestedTwice": { + "unknown": "not-ok" + } + } + }, + "errors": [ + { + "message": "unknown is unknown", + "path": "Patient.nested.nestedTwice.unknown", + "type": "unknown-element" + } + ] + }, + { + "desc": "complex type", + "data": { + "resourceType": "Patient", + "name": { + "family": "ok" + } + } + }, + { + "desc": "complex type unknown", + "data": { + "resourceType": "Patient", + "name": { + "unknown": "not-ok" + } + }, + "errors": [ + { + "message": "unknown is unknown", + "path": "Patient.name.unknown", + "type": "unknown-element" + } + ] + }, + { + "desc": "complex type nested element", + "data": { + "resourceType": "Patient", + "name": { + "nested": { + "element": "ok" + } + } + } + }, + { + "desc": "complex type nested unknown", + "data": { + "resourceType": "Patient", + "name": { + "nested": { + "unknown": "not-ok" + } + } + }, + "errors": [ + { + "message": "unknown is unknown", + "path": "Patient.name.nested.unknown", + "type": "unknown-element" + } + ] + }, + { + "desc": "array", + "data": { + "resourceType": "Patient", + "multielement": [ + "a", + "b" + ] + } + }, + { + "desc": "not array", + "data": { + "resourceType": "Patient", + "multielement": "a" + }, + "errors": [ + { + "message": "multielement is not array", + "path": "Patient.multielement", + "type": "not-array" + } + ] + }, + { + "desc": "singular is array", + "data": { + "resourceType": "Patient", + "status": [ + "active" + ] + }, + "errors": [ + { + "message": "status is not singular", + "path": "Patient.status", + "type": "not-singular" + } + ] + }, + { + "desc": "array object", + "data": { + "resourceType": "Patient", + "multielementObject": [ + { + "element": "a" + }, + { + "element": "b" + } + ] + } + }, + { + "desc": "not array object", + "data": { + "resourceType": "Patient", + "multielementObject": {"element": "a"} + }, + "errors": [ + { + "message": "multielementObject is not array", + "path": "Patient.multielementObject", + "type": "not-array" + } + ] + }, + { + "desc": "complex type wrong not-array", + "data": { + "resourceType": "Patient", + "name": {"given": "ok"} + }, + "errors": [ + { + "message": "given is not array", + "path": "Patient.name.given", + "type": "not-array" + } + ] + }, + {"desc": "complex type wrong array", + "data": { + "resourceType": "Patient", + "name": {"family": ["ok"]} + }, + "errors": [{ + "message": "family is not singular", + "path": "Patient.name.family", + "type": "not-singular" + }] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/2_base.json b/crates/fhir-validator/tests/fixtures/upstream/2_base.json new file mode 100644 index 000000000..95109ba5f --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/2_base.json @@ -0,0 +1,96 @@ +{ + "desc": "base keyword - inheritance", + "schemas": { + "string": { "kind": "primitive-type" }, + "Complex": { + "elements": { + "value": {"type": "string"} + } + }, + "ComplexChild": { + "base": "Complex", + "elements": { + "quantity": {"type": "string"} + } + }, + "Resource": { + "elements": { + "resourceType": {"type": "string"}, + "id": {"type": "string"} + } + }, + "DomainResource": { + "base": "Resource", + "elements": { + "narrative": {"type": "string"} + } + }, + "Patient": { + "base": "DomainResource", + "elements": { + "status": {"type": "string"}, + "complex": {"type": "ComplexChild"} + } + } + }, + "tests": [ + { + "desc": "own property", + "data": { + "resourceType": "Patient", + "status": "active" + } + }, + { + "desc": "father property", + "data": { + "resourceType": "Patient", + "narrative": "ok" + } + }, + { + "desc": "grandfather property", + "data": { + "resourceType": "Patient", + "id": "pt-1" + } + }, + { + "desc": "complex type property", + "data": { + "resourceType": "Patient", + "complex": {"quantity": "ok"} + } + }, + { + "desc": "complex type inherited property", + "data": { + "resourceType": "Patient", + "complex": {"value": "ok"} + } + }, + { + "desc": "complex type property and inherited property", + "data": { + "resourceType": "Patient", + "complex": {"value": "ok", "quantity": "ok"} + } + }, + { + "desc": "complex type unknonw", + "data": { + "resourceType": "Patient", + "complex": { + "unknown": "not-ok", + "value": "ok", + "quantity": "ok" + } + }, + "errors": [{ + "message": "unknown is unknown", + "path": "Patient.complex.unknown", + "type": "unknown-element" + }] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/3_choices.json b/crates/fhir-validator/tests/fixtures/upstream/3_choices.json new file mode 100644 index 000000000..bc359658e --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/3_choices.json @@ -0,0 +1,98 @@ +{ + "desc": "choices keyword", + "schemas": { + "string": { "kind": "primitive-type" }, + "integer": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": {"type": "string"}, + "choice": { + "choices": ["choiceString", "choiceInteger"] + }, + "choiceString": {"choiceOf": "choice", "type": "string"}, + "choiceInteger": {"choiceOf": "choice", "type": "integer"} + } + }, + "Profile": { + "base": "Resource", + "elements": { + "choice": { + "choices": ["choiceString"] + } + } + } + }, + "tests": [ + { + "desc": "choice string", + "data": { + "resourceType": "Resource", + "choiceString": "ok" + } + }, + { + "desc": "choice integer", + "data": { + "resourceType": "Resource", + "choiceInteger": 1 + } + }, + { + "desc": "wrong choice", + "data": { + "resourceType": "Resource", + "choice": "ups" + }, + "errors": [{ + "type": "unknown-element", + "path": "Resource.choice", + "message": "choice is unknown" + }] + }, + { + "desc": "choices conflict - two at the same time", + "data": { + "resourceType": "Resource", + "choiceString": "ok", + "choiceInteger": 1 + }, + "errors": [{ + "type": "choice", + "path": "Resource.choice", + "message": "only one choice for choice allowed, but multiple found: choiceString, choiceInteger" + }] + }, + { + "desc": "unknown element", + "data": { + "resourceType": "Resource", + "unknown": "ups" + }, + "errors": [{ + "type": "unknown-element", + "path": "Resource.unknown", + "message": "unknown is unknown" + }] + }, + { + "desc": "constraint choice ok", + "data": { + "resourceType": "Profile", + "choiceString": "ok" + } + }, + { + "desc": "constraint choice not ok", + "focus": true, + "data": { + "resourceType": "Profile", + "choiceInteger": 1 + }, + "errors": [{ + "message": "choiceInteger is excluded choice", + "path": "Profile.choiceInteger", + "type": "choice-excluded" + }] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/4_required.json b/crates/fhir-validator/tests/fixtures/upstream/4_required.json new file mode 100644 index 000000000..8c894d0d6 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/4_required.json @@ -0,0 +1,165 @@ +{ + "desc": "required keyword", + "schemas": { + "string": { "kind": "primitive-type" }, + "Resource": { + "required": ["requiredElement", "anotherRequiredElement"], + "elements": { + "resourceType": {"type": "string"}, + "requiredElement": {"type": "string"}, + "anotherRequiredElement": {"type": "string"}, + "optionalElement": {"type": "string"}, + "elementForProfile": {"type": "string"} + } + }, + "Profile": { + "base": "Resource", + "required": ["elementForProfile", "localElement"], + "elements": { + "localElement": {"type": "string"} + } + } + }, + "tests": [ + { + "desc": "required satisfied", + "data": { + "resourceType": "Resource", + "requiredElement": "ok", + "anotherRequiredElement": "ok" + } + }, + { + "desc": "one required missed element", + "data": { + "resourceType": "Resource", + "requiredElement": "ok" + }, + "errors": [ + { + "message": "anotherRequiredElement is required", + "type": "required", + "path": "Resource.anotherRequiredElement" + } + ] + }, + { + "desc": "another required missed element", + "data": { + "resourceType": "Resource", + "anotherRequiredElement": "ok" + }, + "errors": [ + { + "message": "requiredElement is required", + "type": "required", + "path": "Resource.requiredElement" + } + ] + }, + { + "desc": "both required missed element", + "data": { + "resourceType": "Resource" + }, + "errors": [ + { + "message": "requiredElement is required", + "type": "required", + "path": "Resource.requiredElement" + }, + { + "message": "anotherRequiredElement is required", + "type": "required", + "path": "Resource.anotherRequiredElement" + } + ] + }, + { + "desc": "required ok in profile", + "data": { + "resourceType": "Profile", + "localElement": "ok", + "elementForProfile": "ok", + "requiredElement": "ok", + "anotherRequiredElement": "ok" + } + }, + { + "desc": "one parent required missed in profile", + "data": { + "resourceType": "Profile", + "localElement": "ok", + "elementForProfile": "ok", + "anotherRequiredElement": "ok" + }, + "errors": [ + { + "message": "requiredElement is required", + "type": "required", + "path": "Profile.requiredElement" + } + ] + }, + { + "desc": "both parent required missed in profile", + "data": { + "resourceType": "Profile", + "localElement": "ok", + "elementForProfile": "ok" + }, + "errors": [ + { + "message": "requiredElement is required", + "type": "required", + "path": "Profile.requiredElement" + }, + { + "message": "anotherRequiredElement is required", + "type": "required", + "path": "Profile.anotherRequiredElement" + } + ] + }, + { + "desc": "elementForProfile missed in profile", + "data": { + "resourceType": "Profile", + "requiredElement": "ok", + "anotherRequiredElement": "ok", + "localElement": "ok" + }, + "errors": [ + { + "message": "elementForProfile is required", + "type": "required", + "path": "Profile.elementForProfile" + } + ] + }, + { + "desc": "all required missed in profile", + "data": { + "resourceType": "Profile", + "localElement": "ok" + }, + "errors": [ + { + "message": "requiredElement is required", + "type": "required", + "path": "Profile.requiredElement" + }, + { + "message": "anotherRequiredElement is required", + "type": "required", + "path": "Profile.anotherRequiredElement" + }, + { + "message": "elementForProfile is required", + "path": "Profile.elementForProfile", + "type": "required" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/5_slices.json b/crates/fhir-validator/tests/fixtures/upstream/5_slices.json new file mode 100644 index 000000000..453af4163 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/5_slices.json @@ -0,0 +1,164 @@ +{ + "desc": "slices keyword", + "schemas": { + "string": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": {"type": "string"}, + "address": { + "array": true, + "elements": { + "use": {"type": "string"}, + "city": {"type": "string"}, + "postalCode": {"type": "string"}, + "line": {"type": "string", "array": true} + } + } + } + }, + "Profile": { + "base": "Resource", + "elements": { + "address": { + "slicing": { + "slices": { + "home": { + "match": {"type": "pattern", "value": {"use": "home"}}, + "min": 1, + "max": 1, + "schema": { "required": ["city"] } + }, + "work": { + "min": 0, + "max": 2, + "match": {"type": "pattern", "value": {"use": "work"}}, + "schema": {"required": ["city", "postalCode"]} + } + } + } + } + } + } + }, + "tests": [ + { + "desc": "valid slices", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home", "city": "CITY"}, + {"use": "work", "city": "CITY", "postalCode": "CODE"} + ] + } + }, + { + "desc": "invalid slice work", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home", "city": "CITY"}, + {"use": "work", "postalCode": "CODE"} + ] + }, + "errors": [ + { + "message": "city is required", + "path": "Profile.address.1.city", + "type": "required" + } + ] + }, + { + "desc": "invalid slice home", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home" }, + {"use": "work", "city": "CITY", "postalCode": "CODE"} + ] + }, + "errors": [ + { + "message": "city is required", + "path": "Profile.address.0.city", + "type": "required" + } + ] + }, + { + "desc": "invalid both slices", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home" }, + {"use": "work", "postalCode": "CODE"} + ] + }, + "errors": [ + { + "message": "city is required", + "path": "Profile.address.0.city", + "type": "required" + }, + { + "message": "city is required", + "path": "Profile.address.1.city", + "type": "required" + } + ] + }, + { + "desc": "missed home slice", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "work", "city": "CITY", "postalCode": "CODE"} + ] + }, + "errors": [ + { + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'", + "path": "Profile.address", + "type": "slice-cardinality" + } + ] + }, + { + "desc": "extra home slice", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home", "city": "CITY", "postalCode": "CODE"}, + {"use": "home", "city": "CITY2", "postalCode": "CODE2"} + ] + }, + "errors": [ + { + "message": "Slice defines the following max cardinality: '1', actual cardinality: '2'", + "path": "Profile.address", + "type": "slice-cardinality" + } + ] + }, + { + "desc": "extra work slice", + "data": { + "resourceType": "Profile", + "address": [ + {"use": "home", "city": "CITY", "postalCode": "CODE"}, + {"use": "work", "city": "CITY1", "postalCode": "CODE1"}, + {"use": "work", "city": "CITY2", "postalCode": "CODE3"}, + {"use": "work", "city": "CITY2", "postalCode": "CODE3"} + ] + }, + "errors": [ + { + "message": "Slice defines the following max cardinality: '2', actual cardinality: '3'", + "path": "Profile.address", + "type": "slice-cardinality" + } + ] + } + + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/6_extensions.json b/crates/fhir-validator/tests/fixtures/upstream/6_extensions.json new file mode 100644 index 000000000..97f115052 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/6_extensions.json @@ -0,0 +1,115 @@ +{ + "desc": "extensions", + "schemas": { + "string": { "kind": "primitive-type" }, + "http://hl7.org/us-core-race": { + "kind": "extension", + "elements": { + "value": { + "choices": ["valueString"] + } + } + }, + "Extension": { + "elements": { + "url": {"type": "string"}, + "value": {"choices": ["valueString", "valueCode"]}, + "valueString": {"choiceOf": "value", "type": "string"}, + "valueCode": {"choiceOf": "value", "type": "string"} + } + }, + "Resource": { + "extensions": { + "us-core-race": { + "url": "http://hl7.org/us-core-race", + "max": 1, + "min": 1 + } + }, + "elements": { + "resourceType": { "type": "string" }, + "extension": { "type": "Extension", "array": true }, + "element": { "type": "string" } + } + } + }, + "tests": [ + { + "desc": "unknown extension - just check the shape", + "data": { + "resourceType": "Resource", + "extension": [ + { + "url": "unknown", + "valueString": "string" + }, + { + "url": "unknown", + "valueCode": "code" + }, + { + "url": "http://hl7.org/us-core-race", + "valueString": "ok" + } + ] + } + }, + { + "desc": "missed extension (no extension element)", + "skip": true, + "comment": "Shall we support it or it should be combination of required and slices like in JSON schema?", + "data": { + "resourceType": "Resource" + }, + "errors": [ + { + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'", + "path": "Resource.extension", + "type": "slice-cardinality" + } + ] + }, + { + "desc": "missed extension (with extension element)", + "data": { + "resourceType": "Resource", + "extension": [ + { + "url": "unknown", + "valueString": "string" + }, + { + "url": "unknown", + "valueCode": "code" + } + ] + }, + "errors": [ + { + "message": "Slice defines the following min cardinality: '1', actual cardinality: '0'", + "path": "Resource.extension", + "type": "slice-cardinality" + } + ] + }, + { + "desc": "broken extension schema", + "data": { + "resourceType": "Resource", + "extension": [ + { + "url": "http://hl7.org/us-core-race", + "valueCode": "wrong" + } + ] + }, + "errors": [ + { + "message": "valueCode is excluded choice", + "path": "Resource.extension.0.valueCode", + "type": "choice-excluded" + } + ] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/7_bundles.json b/crates/fhir-validator/tests/fixtures/upstream/7_bundles.json new file mode 100644 index 000000000..11dd13059 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/7_bundles.json @@ -0,0 +1,96 @@ +{ + "desc": "bundles", + "schemas": { + "string": { "kind": "primitive-type" }, + "Resource": { + "elements": { + "resourceType": { "type": "string" }, + "contained": { + "array": true, + "type": "Resource" + } + } + }, + "Patient": { + "base": "Resource", + "elements": {"name": { "type": "string" }} + }, + "Encounter": { + "base": "Resource", + "elements": {"type": { "type": "string" }} + }, + "Bundle": { + "base": "Resource", + "elements": { + "entry": { + "array": true, + "elements": { + "resource": { + "type": "Resource" + } + } + } + } + } + }, + "tests": [ + { + "desc": "ok bundle", + "data": { + "resourceType": "Bundle", + "entry": [ + {"resource": {"resourceType": "Patient", "name": "John"}}, + {"resource": {"resourceType": "Encounter", "type": "in-patient"}} + ] + } + }, + { + "desc": "pt contained", + "data": { + "resourceType": "Patient", + "contained": [ + {"resourceType": "Encounter", "type": "in-patient"} + ] + } + }, + { + "desc": "enc contained", + "data": { + "resourceType": "Encounter", + "contained": [ + {"resourceType": "Patient", "name": "John"} + ] + } + }, + { + "desc": "broken bundle", + "data": { + "resourceType": "Bundle", + "entry": [ + {"resource": {"resourceType": "Patient", "extra": "ups"}} + ] + }, + "errors": + [{ + "message": "extra is unknown", + "path": "Bundle.entry.0.resource.extra", + "type": "unknown-element" + }] + }, + { + "desc": "broken bundle", + "data": { + "resourceType": "Bundle", + "entry": [ + {"resource": {"resourceType": "Encounter", "extra": "ups"}} + ] + }, + "errors": + [{ + "message": "extra is unknown", + "path": "Bundle.entry.0.resource.extra", + "type": "unknown-element" + }] + } + ] +} diff --git a/crates/fhir-validator/tests/fixtures/upstream/UPSTREAM.md b/crates/fhir-validator/tests/fixtures/upstream/UPSTREAM.md new file mode 100644 index 000000000..7da0f48e8 --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/upstream/UPSTREAM.md @@ -0,0 +1,27 @@ +# Upstream FHIR Schema conformance fixtures + +The `*.json` files in this directory are vendored, unmodified, from the +**FHIR Schema** project's language-agnostic conformance suite: + +- Source repository: https://github.com/dougc95/fhir-schema (fork of + https://github.com/fhir-schema/fhir-schema) +- Vendored from local clone at commit: `dff20652992ee2f27d46122598acdf4356298c92` + (2026-07-03) +- Upstream path: `tests/*.json` +- License: MIT (Copyright (c) 2018-2023 Nikolai Ryzhikov, Ewout Kramer, + FHIR Community, Health Samurai, Firely) — see the upstream `LICENSE.md`. + +These fixtures are the behavioral contract for the validation engine in this +crate: each file bundles inline schemas, resource instances, and the exact +error objects (`{type, path, message}`) a conforming validator must emit, in +order. The harness in `../../conformance.rs` runs them with **exact ordered +deep-equality** — message strings included. + +Do not edit these files. Helios-specific behavior beyond the upstream contract +(primitive value checks, `excluded`, numeric cardinality when absent, slicing +rules, etc.) is pinned by our own fixtures in `../extended/` instead. + +Fixture semantics honored by the harness: +- a test without an `errors` key must validate clean (`[]`) +- `skip: true` tests are skipped (known-unimplemented upstream behavior) +- `focus: true` is ignored — every non-skipped test always runs diff --git a/crates/fhir-validator/tests/fuzz_lite.rs b/crates/fhir-validator/tests/fuzz_lite.rs new file mode 100644 index 000000000..596af8379 --- /dev/null +++ b/crates/fhir-validator/tests/fuzz_lite.rs @@ -0,0 +1,139 @@ +//! Fuzz-lite robustness sweep: the validator must never panic, whatever +//! JSON it is fed. Deterministic (seeded xorshift, no clock/rand deps) so +//! failures reproduce. + +#![cfg(feature = "R4")] + +use helios_fhir::FhirVersion; +use helios_fhir_validator::packs::core_registry; +use helios_fhir_validator::{ValidationOptions, Validator}; +use serde_json::{json, Value}; + +/// Tiny deterministic PRNG (xorshift64*). +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +/// A pool of pathological values to splice into resources. +fn junk(rng: &mut Rng) -> Value { + match rng.below(12) { + 0 => Value::Null, + 1 => json!(true), + 2 => json!(-1), + 3 => json!(1e308), + 4 => json!(""), + 5 => json!("\u{0}\u{ffff}"), + 6 => json!([]), + 7 => json!({}), + 8 => json!([null, [null, [null]]]), + 9 => json!({"resourceType": null}), + 10 => json!({"resourceType": "Patient", "resourceType2": {"a": [{}]}}), + _ => json!({"_": {"__": {"___": []}}}), + } +} + +/// Mutate a node in place: replace, delete a key, insert junk, or recurse. +fn mutate(value: &mut Value, rng: &mut Rng, depth: usize) { + if depth > 6 { + return; + } + match rng.below(5) { + 0 => *value = junk(rng), + 1 => { + if let Some(obj) = value.as_object_mut() { + if let Some(key) = obj.keys().nth(rng.below(obj.len().max(1))).cloned() { + obj.remove(&key); + } + } else { + *value = junk(rng); + } + } + 2 => { + if let Some(obj) = value.as_object_mut() { + let j = junk(rng); + let names = ["x", "_x", "extension", "value", "resourceType", "contained", "9"]; + obj.insert(names[rng.below(names.len())].to_string(), j); + } else if let Some(arr) = value.as_array_mut() { + let j = junk(rng); + arr.push(j); + } + } + _ => { + let target = match value { + Value::Object(obj) if !obj.is_empty() => { + let idx = rng.below(obj.len()); + obj.values_mut().nth(idx) + } + Value::Array(arr) if !arr.is_empty() => { + let idx = rng.below(arr.len()); + arr.get_mut(idx) + } + _ => None, + }; + match target { + Some(inner) => mutate(inner, rng, depth + 1), + None => *value = junk(rng), + } + } + } +} + +fn seed_patient() -> Value { + json!({ + "resourceType": "Patient", + "id": "fuzz", + "meta": { "profile": ["http://example.org/nope"] }, + "extension": [{ "url": "http://x", "valueString": "v" }], + "identifier": [{ "system": "http://example.org", "value": "1" }], + "active": true, + "name": [{ "family": "F", "given": ["G", null] }], + "_birthDate": { "id": "b" }, + "birthDate": "1980-01-01", + "deceasedBoolean": false, + "contained": [{ "resourceType": "Organization", "id": "o", "name": "N" }], + "link": [{ "other": { "reference": "#o" }, "type": "seealso" }] + }) +} + +#[test] +fn never_panics_on_mutated_resources() { + let validator = Validator::new(core_registry(FhirVersion::R4)); + let opts = ValidationOptions::default(); + let mut rng = Rng(0x5EED_CAFE_F00D_D00D); + + for round in 0..2000 { + let mut resource = seed_patient(); + // Escalate mutation aggressiveness with the round number. + for _ in 0..(1 + round % 7) { + mutate(&mut resource, &mut rng, 0); + } + // Must return (errors are fine, panics are not). + let _ = validator.validate_sync(&resource, &opts); + } +} + +#[test] +fn never_panics_on_pure_junk_roots() { + let validator = Validator::new(core_registry(FhirVersion::R4)); + let opts = ValidationOptions::default(); + let mut rng = Rng(0xBAD_5EED); + + for _ in 0..500 { + let mut root = junk(&mut rng); + mutate(&mut root, &mut rng, 0); + let _ = validator.validate_sync(&root, &opts); + } +} diff --git a/crates/fhir-validator/tests/pack_smoke.rs b/crates/fhir-validator/tests/pack_smoke.rs new file mode 100644 index 000000000..7dbb786f7 --- /dev/null +++ b/crates/fhir-validator/tests/pack_smoke.rs @@ -0,0 +1,186 @@ +//! Whole-spec smoke tests over the embedded packs. `#[ignore]`d in normal +//! runs (they parse full packs); run explicitly with +//! `cargo test -p helios-fhir-validator -- --ignored` +//! (add `--features R4B,R5,R6` for the other-version sweeps). + +#![cfg(feature = "R4")] + +use helios_fhir::FhirVersion; +use helios_fhir_validator::packs::core_registry; +use helios_fhir_validator::{SchemaResolver, ValidationOptions, Validator}; +use serde_json::json; + +/// Every enabled version's pack loads and validates a minimal Patient. +#[test] +#[ignore = "whole-pack parse; run with -- --ignored"] +fn all_enabled_packs_load_and_validate() { + let versions = [ + FhirVersion::R4, + #[cfg(feature = "R4B")] + FhirVersion::R4B, + #[cfg(feature = "R5")] + FhirVersion::R5, + #[cfg(feature = "R6")] + FhirVersion::R6, + ]; + for version in versions { + let registry = core_registry(version); + for name in ["Patient", "Observation", "Bundle", "Resource", "Element", "string"] { + assert!( + registry.resolve(name).is_some(), + "{version:?}: core schema '{name}' must resolve" + ); + } + let validator = Validator::new(registry); + let outcome = validator.validate_sync( + &json!({ "resourceType": "Patient", "active": true }), + &ValidationOptions::default(), + ); + assert_eq!(outcome.errors, vec![], "{version:?}: minimal Patient must be clean"); + let outcome = validator.validate_sync( + &json!({ "resourceType": "Patient", "bogus": 1 }), + &ValidationOptions::default(), + ); + assert!(!outcome.errors.is_empty(), "{version:?}: unknown element must be caught"); + } +} + +/// Rough structural-validation latency check (debug builds are far slower +/// than release; the plan's <5ms target refers to release). +#[test] +#[ignore = "timing; run with -- --ignored"] +fn structural_validation_latency_smoke() { + let validator = Validator::new(core_registry(FhirVersion::R4)); + let opts = ValidationOptions::default(); + let patient = json!({ + "resourceType": "Patient", + "id": "perf", + "extension": [{ "url": "http://x", "valueString": "v" }], + "identifier": [{ "system": "http://example.org/mrn", "value": "12345" }], + "active": true, + "name": [{ "use": "official", "family": "Chalmers", "given": ["Peter", "James"] }], + "gender": "male", + "birthDate": "1974-12-25", + "deceasedBoolean": false, + "contained": [{ "resourceType": "Organization", "id": "org1", "name": "ACME" }], + "managingOrganization": { "reference": "#org1" } + }); + + // Warm the lazy pack parse before timing. + let _ = validator.validate_sync(&patient, &opts); + + let iterations = 200u32; + let start = std::time::Instant::now(); + for _ in 0..iterations { + let outcome = validator.validate_sync(&patient, &opts); + assert!(outcome.errors.is_empty()); + } + let per_run = start.elapsed() / iterations; + println!("structural validation: {per_run:?} per typical Patient"); + // Generous ceiling so debug builds pass; release comfortably beats the + // 5ms plan target (verified manually). + assert!(per_run.as_millis() < 50, "structural validation too slow: {per_run:?}"); +} + +#[test] +#[ignore = "whole-pack parse; run with -- --ignored"] +fn r4_pack_loads_and_resolves_core_schemas() { + let registry = core_registry(FhirVersion::R4); + for name in ["Patient", "Observation", "Bundle", "Resource", "DomainResource", "Element", + "Extension", "HumanName", "string", "boolean", "dateTime", "Questionnaire"] + { + assert!(registry.resolve(name).is_some(), "core schema '{name}' must resolve"); + } + // Canonical URLs resolve to the same schemas. + let by_name = registry.resolve("Patient").unwrap(); + let by_url = registry.resolve("http://hl7.org/fhir/StructureDefinition/Patient").unwrap(); + assert!(std::sync::Arc::ptr_eq(&by_name, &by_url)); + // Primitives carry their value regexes. + assert!(registry.resolve("string").unwrap().regex.is_some()); + // Questionnaire.item recursion converted to an elementReference. + let q = registry.resolve("Questionnaire").unwrap(); + let item = &q.elements.as_ref().unwrap()["item"]; + let nested = &item.elements.as_ref().unwrap()["item"]; + assert_eq!( + nested.element_reference.as_deref(), + Some(&["Questionnaire".to_string(), "elements".to_string(), "item".to_string()][..]) + ); +} + +#[test] +#[ignore = "whole-pack parse; run with -- --ignored"] +fn r4_pack_validates_known_good_and_bad_resources() { + let registry = core_registry(FhirVersion::R4); + let validator = Validator::new(registry); + let opts = ValidationOptions::default(); + + // A well-formed Patient with common shapes: choice type, arrays, + // primitive sidecar, contained resource, extension. + let good = json!({ + "resourceType": "Patient", + "id": "example", + "meta": { "versionId": "1" }, + "extension": [{ + "url": "http://example.org/unknown-extension", + "valueString": "free-form" + }], + "identifier": [{ "system": "http://example.org/mrn", "value": "12345" }], + "active": true, + "name": [{ "use": "official", "family": "Chalmers", "given": ["Peter", "James"] }], + "gender": "male", + "birthDate": "1974-12-25", + "_birthDate": { + "extension": [{ + "url": "http://hl7.org/fhir/StructureDefinition/patient-birthTime", + "valueDateTime": "1974-12-25T14:35:45-05:00" + }] + }, + "deceasedBoolean": false, + "contained": [{ + "resourceType": "Organization", + "id": "org1", + "name": "ACME Healthcare" + }], + "managingOrganization": { "reference": "#org1" } + }); + let outcome = validator.validate_sync(&good, &opts); + assert_eq!( + outcome.errors, + vec![], + "known-good Patient must validate clean, got: {}", + serde_json::to_string_pretty(&outcome.errors).unwrap() + ); + + // Structural breakage must surface. + let bad = json!({ + "resourceType": "Patient", + "bogusElement": true, + "gender": ["male"], + "name": { "family": "NotAnArray" }, + "deceasedBoolean": false, + "deceasedDateTime": "2020-01-01" + }); + let outcome = validator.validate_sync(&bad, &opts); + let kinds: Vec = outcome + .errors + .iter() + .map(|e| serde_json::to_value(e.kind).unwrap().as_str().unwrap().to_string()) + .collect(); + assert!(kinds.contains(&"unknown-element".to_string()), "kinds: {kinds:?}"); + assert!(kinds.contains(&"not-singular".to_string()), "kinds: {kinds:?}"); + assert!(kinds.contains(&"not-array".to_string()), "kinds: {kinds:?}"); + assert!(kinds.contains(&"choice".to_string()), "kinds: {kinds:?}"); + + // A Bundle whose entry resource is dynamically resolved. + let bundle = json!({ + "resourceType": "Bundle", + "type": "collection", + "entry": [{ "resource": { "resourceType": "Patient", "wrong": 1 } }] + }); + let outcome = validator.validate_sync(&bundle, &opts); + assert!( + outcome.errors.iter().any(|e| e.path == "Bundle.entry.0.resource.wrong"), + "dynamic resolution must reach the nested Patient, got: {}", + serde_json::to_string_pretty(&outcome.errors).unwrap() + ); +} diff --git a/crates/rest/Cargo.toml b/crates/rest/Cargo.toml index 0e407da1c..3acd04bf9 100644 --- a/crates/rest/Cargo.toml +++ b/crates/rest/Cargo.toml @@ -14,11 +14,11 @@ categories = ["web-programming", "database"] [features] default = ["R4", "sqlite"] -# FHIR version features (pass through to helios-fhir, helios-persistence, helios-audit, helios-serde, helios-sof, helios-subscriptions) -R4 = ["helios-fhir/R4", "helios-persistence/R4", "helios-audit/R4", "helios-serde?/R4", "helios-sof/R4", "helios-subscriptions?/R4"] -R4B = ["helios-fhir/R4B", "helios-persistence/R4B", "helios-audit/R4B", "helios-serde?/R4B", "helios-sof/R4B", "helios-subscriptions?/R4B"] -R5 = ["helios-fhir/R5", "helios-persistence/R5", "helios-audit/R5", "helios-serde?/R5", "helios-sof/R5", "helios-subscriptions?/R5"] -R6 = ["helios-fhir/R6", "helios-persistence/R6", "helios-audit/R6", "helios-serde?/R6", "helios-sof/R6", "helios-subscriptions?/R6"] +# FHIR version features (pass through to helios-fhir, helios-persistence, helios-audit, helios-serde, helios-sof, helios-subscriptions, helios-fhir-validator) +R4 = ["helios-fhir/R4", "helios-persistence/R4", "helios-audit/R4", "helios-serde?/R4", "helios-sof/R4", "helios-subscriptions?/R4", "helios-fhir-validator/R4"] +R4B = ["helios-fhir/R4B", "helios-persistence/R4B", "helios-audit/R4B", "helios-serde?/R4B", "helios-sof/R4B", "helios-subscriptions?/R4B", "helios-fhir-validator/R4B"] +R5 = ["helios-fhir/R5", "helios-persistence/R5", "helios-audit/R5", "helios-serde?/R5", "helios-sof/R5", "helios-subscriptions?/R5", "helios-fhir-validator/R5"] +R6 = ["helios-fhir/R6", "helios-persistence/R6", "helios-audit/R6", "helios-serde?/R6", "helios-sof/R6", "helios-subscriptions?/R6", "helios-fhir-validator/R6"] # Serialization format features xml = ["helios-fhir/xml", "dep:helios-serde", "helios-serde?/xml"] @@ -50,6 +50,7 @@ helios-sof = { path = "../sof", version = "0.2.1", default-features = false } helios-auth = { path = "../auth", version = "0.2.1" } helios-audit = { path = "../audit", version = "0.2.1", default-features = false } helios-subscriptions = { path = "../subscriptions", version = "0.2.1", optional = true, default-features = false } +helios-fhir-validator = { path = "../fhir-validator", version = "0.2.1", default-features = false, features = ["fhirpath"] } helios-observability = { path = "../observability", version = "0.2.1" } # Async runtime diff --git a/crates/rest/src/config.rs b/crates/rest/src/config.rs index 1fca49a6f..b2db5beda 100644 --- a/crates/rest/src/config.rs +++ b/crates/rest/src/config.rs @@ -23,6 +23,15 @@ //! | `HFS_TENANT_STRICT_VALIDATION` | false | Error if URL and header tenant disagree | //! | `HFS_JWT_TENANT_CLAIM` | tenant_id | JWT claim name for tenant (future use) | //! | `HFS_TERMINOLOGY_SERVER` | (none) | HTS base URL for `:in`/`:not-in` search and FHIRPath terminology functions | +//! | `HFS_VALIDATION_MODE` | off | Write-path validation: off, log, or enforce (422 on invalid) | +//! | `HFS_VALIDATION_META_PROFILES` | true | Validate against `meta.profile` claims | +//! | `HFS_VALIDATION_UNKNOWN_PROFILE` | warn | Unresolvable profiles: warn, error, or ignore | +//! | `HFS_VALIDATION_CONSTRAINTS` | true | Evaluate FHIRPath invariants | +//! | `HFS_VALIDATION_SUPPRESS_CONSTRAINTS` | dom-6 | Comma-separated constraint ids to skip | +//! | `HFS_VALIDATION_TERMINOLOGY` | off | Required-binding checks: off or remote (`$validate-code` against `HFS_TERMINOLOGY_SERVER`) | +//! | `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 | //! //! # Example //! @@ -426,6 +435,136 @@ impl BulkExportConfig { } } +/// Resource validation configuration, loaded from `HFS_VALIDATION_*` +/// environment variables. +/// +/// The `$validate` operation is always available; these settings gate the +/// **write-path** behavior (create/update/batch) and tune the shared +/// validation service. +#[derive(Debug, Clone)] +pub struct ValidationConfig { + /// Write-path behavior: `off` (skip), `log` (validate, log issues, + /// proceed), or `enforce` (reject invalid resources with `422`). + pub mode: String, + /// Validate against the profiles a resource claims in `meta.profile`. + pub meta_profiles: bool, + /// Unresolvable profile references: `warn`, `error`, or `ignore`. + pub unknown_profile: String, + /// Evaluate FHIRPath invariant constraints. + pub constraints: bool, + /// Constraint ids never evaluated (comma-separated in the env var). + pub suppress_constraints: Vec, + /// Terminology binding checking: `off` or `remote` + /// (`remote` uses `HFS_TERMINOLOGY_SERVER`'s `ValueSet/$validate-code`). + pub terminology: String, + /// Per-check terminology timeout, in milliseconds. + pub terminology_timeout_ms: u64, + /// Terminology outages: `open` (warn and proceed) or `closed` + /// (treat as validation errors). + pub terminology_fail: String, + /// Maintain per-tenant profile registries from stored + /// StructureDefinitions (updated on StructureDefinition writes). + pub stored_profiles: bool, +} + +impl Default for ValidationConfig { + fn default() -> Self { + Self { + mode: "off".to_string(), + meta_profiles: true, + unknown_profile: "warn".to_string(), + constraints: true, + suppress_constraints: vec!["dom-6".to_string()], + terminology: "off".to_string(), + terminology_timeout_ms: 3000, + terminology_fail: "open".to_string(), + stored_profiles: true, + } + } +} + +impl ValidationConfig { + /// Loads validation configuration from `HFS_VALIDATION_*` env vars. + pub fn from_env() -> Self { + fn env_bool(key: &str, default: bool) -> bool { + std::env::var(key) + .map(|s| { + let s = s.to_lowercase(); + s == "true" || s == "1" + }) + .unwrap_or(default) + } + fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) + } + let d = Self::default(); + Self { + mode: std::env::var("HFS_VALIDATION_MODE").unwrap_or(d.mode), + meta_profiles: env_bool("HFS_VALIDATION_META_PROFILES", d.meta_profiles), + unknown_profile: std::env::var("HFS_VALIDATION_UNKNOWN_PROFILE") + .unwrap_or(d.unknown_profile), + constraints: env_bool("HFS_VALIDATION_CONSTRAINTS", d.constraints), + suppress_constraints: std::env::var("HFS_VALIDATION_SUPPRESS_CONSTRAINTS") + .map(|s| { + s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or(d.suppress_constraints), + terminology: std::env::var("HFS_VALIDATION_TERMINOLOGY").unwrap_or(d.terminology), + terminology_timeout_ms: env_u64( + "HFS_VALIDATION_TERMINOLOGY_TIMEOUT_MS", + d.terminology_timeout_ms, + ), + 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), + } + } + + /// Validates the validation configuration. + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + if !matches!(self.mode.as_str(), "off" | "log" | "enforce") { + errors.push(format!( + "HFS_VALIDATION_MODE '{}' invalid (expected off|log|enforce)", + self.mode + )); + } + if !matches!(self.unknown_profile.as_str(), "warn" | "error" | "ignore") { + errors.push(format!( + "HFS_VALIDATION_UNKNOWN_PROFILE '{}' invalid (expected warn|error|ignore)", + self.unknown_profile + )); + } + if !matches!(self.terminology.as_str(), "off" | "remote") { + errors.push(format!( + "HFS_VALIDATION_TERMINOLOGY '{}' invalid (expected off|remote)", + self.terminology + )); + } + if !matches!(self.terminology_fail.as_str(), "open" | "closed") { + errors.push(format!( + "HFS_VALIDATION_TERMINOLOGY_FAIL '{}' invalid (expected open|closed)", + self.terminology_fail + )); + } + if self.terminology_timeout_ms == 0 { + errors.push("HFS_VALIDATION_TERMINOLOGY_TIMEOUT_MS must be > 0".to_string()); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + /// Bulk Data **Submit** (`$bulk-submit`) configuration, loaded from /// `HFS_BULK_SUBMIT_*` environment variables. /// @@ -861,6 +1000,10 @@ pub struct ServerConfig { /// Bulk data submit configuration (loaded from environment variables). #[arg(skip)] pub bulk_submit: BulkSubmitConfig, + + /// Resource validation configuration (loaded from environment variables). + #[arg(skip)] + pub validation: ValidationConfig, } impl ServerConfig { @@ -917,6 +1060,7 @@ impl Default for ServerConfig { multitenancy: MultitenancyConfig::default(), bulk_export: BulkExportConfig::default(), bulk_submit: BulkSubmitConfig::default(), + validation: ValidationConfig::default(), } } } @@ -935,6 +1079,8 @@ impl ServerConfig { config.bulk_export = BulkExportConfig::from_env(); // Load bulk submit config from environment config.bulk_submit = BulkSubmitConfig::from_env(); + // Load validation config from environment + config.validation = ValidationConfig::from_env(); config } @@ -980,6 +1126,17 @@ impl ServerConfig { errors.append(&mut submit_errors); } + if let Err(mut validation_errors) = self.validation.validate() { + errors.append(&mut validation_errors); + } + + // Remote terminology checking needs a terminology server to call. + if self.validation.terminology == "remote" && self.terminology_server.is_none() { + errors.push( + "HFS_VALIDATION_TERMINOLOGY=remote requires HFS_TERMINOLOGY_SERVER".to_string(), + ); + } + if errors.is_empty() { Ok(()) } else { @@ -1037,6 +1194,7 @@ impl ServerConfig { multitenancy: MultitenancyConfig::default(), bulk_export: BulkExportConfig::default(), bulk_submit: BulkSubmitConfig::default(), + validation: ValidationConfig::default(), } } diff --git a/crates/rest/src/error.rs b/crates/rest/src/error.rs index c2009ff59..e29eb3580 100644 --- a/crates/rest/src/error.rs +++ b/crates/rest/src/error.rs @@ -116,6 +116,13 @@ pub enum RestError { message: String, }, + /// Resource validation failed in enforce mode (HTTP 422). Carries the + /// full multi-issue OperationOutcome from the validator. + ValidationFailed { + /// The OperationOutcome to return as the response body. + outcome: serde_json::Value, + }, + /// Unauthorized — missing or invalid authentication (HTTP 401). Unauthorized { /// Error message. @@ -231,6 +238,9 @@ impl fmt::Display for RestError { RestError::UnprocessableEntity { message } => { write!(f, "Unprocessable entity: {}", message) } + RestError::ValidationFailed { .. } => { + write!(f, "Resource validation failed") + } RestError::Unauthorized { message } => { write!(f, "Unauthorized: {}", message) } @@ -335,6 +345,11 @@ impl RestError { "processing", message.clone(), ), + RestError::ValidationFailed { .. } => ( + StatusCode::UNPROCESSABLE_ENTITY, + "processing", + "Resource validation failed".to_string(), + ), RestError::Unauthorized { message } => { (StatusCode::UNAUTHORIZED, "login", message.clone()) } @@ -386,6 +401,12 @@ impl RestError { impl IntoResponse for RestError { fn into_response(self) -> Response { + // ValidationFailed carries a fully-formed OperationOutcome (potentially + // many issues from the write-path validator); surface it verbatim + // rather than collapsing it to the generic single-issue shape. + if let RestError::ValidationFailed { outcome } = &self { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(outcome.clone())).into_response(); + } let (status, code, details) = self.client_response(); let operation_outcome = create_operation_outcome("error", code, &details); diff --git a/crates/rest/src/handlers/batch.rs b/crates/rest/src/handlers/batch.rs index d5a71dcfa..126959fe4 100644 --- a/crates/rest/src/handlers/batch.rs +++ b/crates/rest/src/handlers/batch.rs @@ -227,6 +227,28 @@ where } } + // Write-path validation: transactions are atomic, so any invalid write + // entry rejects the whole bundle before anything executes. + for (index, entry, _) in &indexed_entries { + if !matches!(entry.method, BundleMethod::Post | BundleMethod::Put) { + continue; + } + let Some(resource) = &entry.resource else { + continue; + }; + let (resource_type, _) = parse_request_url(&entry.url) + .map_err(|e| RestError::BadRequest { message: format!("Entry {}: {}", index, e) })?; + state + .validation() + .check_write( + tenant.tenant_id(), + FhirVersion::default_enabled(), + &resource_type, + resource, + ) + .await?; + } + // Sort by processing order: DELETE -> POST -> PUT/PATCH -> GET indexed_entries.sort_by_key(|(_, entry, _)| method_processing_order(&entry.method)); @@ -248,6 +270,23 @@ where match result { Ok(bundle_result) => { + // Stored StructureDefinitions feed the tenant's profile + // registry. The request content is what was stored (modulo + // server-assigned id/meta, which the converter does not read). + for (_, entry, _) in &indexed_entries { + if matches!(entry.method, BundleMethod::Post | BundleMethod::Put) + && let Some(resource) = &entry.resource + && resource.get("resourceType").and_then(Value::as_str) + == Some("StructureDefinition") + { + state.validation().upsert_stored_profile( + tenant.tenant_id(), + FhirVersion::default_enabled(), + resource, + ); + } + } + // Reorder results back to original entry order let mut ordered_results: Vec<(usize, &BundleEntry, &BundleEntryResult)> = indexed_entries @@ -389,6 +428,20 @@ where } }; + // Write-path validation (per-entry outcome in batch semantics). + if let Err(e) = state + .validation() + .check_write( + tenant.tenant_id(), + FhirVersion::default_enabled(), + &resource_type, + &resource, + ) + .await + { + return create_error_result(422, &validation_failure_message(&e)); + } + // Use default FHIR version for batch operations match state .storage() @@ -400,7 +453,16 @@ where ) .await { - Ok(stored) => BundleEntryResult::created(stored), + Ok(stored) => { + if resource_type == "StructureDefinition" { + state.validation().upsert_stored_profile( + tenant.tenant_id(), + FhirVersion::default_enabled(), + stored.content(), + ); + } + BundleEntryResult::created(stored) + } Err(e) => { let (status, message) = entry_error(e); create_error_result(status, &message) @@ -416,6 +478,20 @@ where } }; + // Write-path validation (per-entry outcome in batch semantics). + if let Err(e) = state + .validation() + .check_write( + tenant.tenant_id(), + FhirVersion::default_enabled(), + &resource_type, + &resource, + ) + .await + { + return create_error_result(422, &validation_failure_message(&e)); + } + // Use default FHIR version for batch operations match state .storage() @@ -429,6 +505,13 @@ where .await { Ok((stored, created)) => { + if resource_type == "StructureDefinition" { + state.validation().upsert_stored_profile( + tenant.tenant_id(), + FhirVersion::default_enabled(), + stored.content(), + ); + } if created { BundleEntryResult::created(stored) } else { @@ -681,6 +764,33 @@ fn parse_request_url(url: &str) -> Result<(String, String), String> { } /// Creates an error BundleEntryResult. +/// Flatten an enforce-mode validation failure into a per-entry message +/// (batch entry outcomes are message-based). +fn validation_failure_message(error: &RestError) -> String { + if let RestError::ValidationFailed { outcome } = error { + let details: Vec = outcome + .get("issue") + .and_then(|i| i.as_array()) + .map(|issues| { + issues + .iter() + .filter_map(|issue| { + issue + .get("details") + .and_then(|d| d.get("text")) + .and_then(|t| t.as_str()) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(); + if !details.is_empty() { + return format!("Validation failed: {}", details.join("; ")); + } + } + format!("Validation failed: {error}") +} + fn create_error_result(status: u16, message: &str) -> BundleEntryResult { let outcome = serde_json::json!({ "resourceType": "OperationOutcome", diff --git a/crates/rest/src/handlers/create.rs b/crates/rest/src/handlers/create.rs index 088c30643..20f24138b 100644 --- a/crates/rest/src/handlers/create.rs +++ b/crates/rest/src/handlers/create.rs @@ -104,6 +104,12 @@ where }); } + // Write-path validation (HFS_VALIDATION_MODE: off | log | enforce). + state + .validation() + .check_write(tenant.tenant_id(), fhir_version, &resource_type, &resource) + .await?; + // Check for conditional create if let Some(search_params) = conditional.if_none_exist() { debug!(search_params = %search_params, "Processing conditional create"); @@ -122,6 +128,15 @@ where use helios_persistence::core::ConditionalCreateResult; return match result { ConditionalCreateResult::Created(stored) => { + // Stored StructureDefinitions feed the tenant's profile + // registry. + if resource_type == "StructureDefinition" { + state.validation().upsert_stored_profile( + tenant.tenant_id(), + fhir_version, + stored.content(), + ); + } let headers = ResourceHeaders::from_stored(&stored, &state); let location = format!("{}/{}/{}", state.base_url(), resource_type, stored.id()); @@ -192,6 +207,13 @@ where .create(tenant.context(), &resource_type, resource, fhir_version) .await?; + // Stored StructureDefinitions feed the tenant's profile registry. + if resource_type == "StructureDefinition" { + state + .validation() + .upsert_stored_profile(tenant.tenant_id(), fhir_version, stored.content()); + } + let headers = ResourceHeaders::from_stored(&stored, &state); let location = format!("{}/{}/{}", state.base_url(), resource_type, stored.id()); diff --git a/crates/rest/src/handlers/mod.rs b/crates/rest/src/handlers/mod.rs index c5ca30c3d..84308293a 100644 --- a/crates/rest/src/handlers/mod.rs +++ b/crates/rest/src/handlers/mod.rs @@ -40,6 +40,7 @@ pub mod subscription_event; pub mod subscriptions; pub mod update; pub mod user_settings; +pub mod validate; pub mod versions; pub mod vread; #[cfg(feature = "subscriptions")] @@ -87,5 +88,8 @@ pub use reindex::{ pub use search::{search_get_handler, search_post_handler}; pub use update::{conditional_update_handler, update_handler}; pub use user_settings::{get_user_settings, patch_user_settings, put_user_settings}; +pub use validate::{ + validate_instance_get_handler, validate_instance_post_handler, validate_type_handler, +}; pub use versions::versions_handler; pub use vread::vread_handler; diff --git a/crates/rest/src/handlers/update.rs b/crates/rest/src/handlers/update.rs index bae778ec1..18b00527c 100644 --- a/crates/rest/src/handlers/update.rs +++ b/crates/rest/src/handlers/update.rs @@ -125,6 +125,12 @@ where }); } + // Write-path validation (HFS_VALIDATION_MODE: off | log | enforce). + state + .validation() + .check_write(tenant.tenant_id(), fhir_version, &resource_type, &resource) + .await?; + // Try to read existing resource for version check let existing = state .storage() @@ -169,6 +175,13 @@ where ) .await?; + // Stored StructureDefinitions feed the tenant's profile registry. + if resource_type == "StructureDefinition" { + state + .validation() + .upsert_stored_profile(tenant.tenant_id(), fhir_version, stored.content()); + } + let headers = ResourceHeaders::from_stored(&stored, &state); let status = if created { StatusCode::CREATED @@ -276,6 +289,12 @@ where } } + // Write-path validation (HFS_VALIDATION_MODE: off | log | enforce). + state + .validation() + .check_write(tenant.tenant_id(), fhir_version, &resource_type, &resource) + .await?; + let result = state .storage() .conditional_update( diff --git a/crates/rest/src/handlers/validate.rs b/crates/rest/src/handlers/validate.rs new file mode 100644 index 000000000..65147225c --- /dev/null +++ b/crates/rest/src/handlers/validate.rs @@ -0,0 +1,283 @@ +//! `$validate` operation handlers. +//! +//! Implements the FHIR [Resource-validate operation](https://hl7.org/fhir/OperationDefinition/Resource-validate): +//! +//! - `POST [base]/[type]/$validate` — validate the body (a raw resource or +//! a `Parameters` wrapper carrying `resource`/`mode`/`profile` parts) +//! - `GET [base]/[type]/[id]/$validate` — validate the **stored** resource +//! - `POST [base]/[type]/[id]/$validate` — validate the body in the context +//! of the addressed instance (update semantics) +//! +//! Per the operation definition, validation **always returns `200 OK`** +//! with an `OperationOutcome` — an invalid resource is a successful +//! validation with error issues. Non-200 responses are reserved for +//! malformed requests (unknown type, missing body, bad `Parameters`, +//! `mode=profile` without a profile). +//! +//! `mode=delete` performs no content validation (no referential-integrity +//! checking yet) and reports the all-clear outcome. + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use helios_persistence::core::ResourceStorage; +use serde::Deserialize; +use serde_json::Value; +use tracing::debug; + +use crate::error::{RestError, RestResult}; +use crate::extractors::{FhirResource, FhirVersionExtractor, TenantExtractor}; +use crate::middleware::content_type::negotiate_format; +use crate::responses::format_resource_response; +use crate::responses::operation_outcome::{IssueType, OperationOutcomeBuilder}; +use crate::state::AppState; +use crate::validation::validation_outcome; + +/// Query-string fallbacks for the operation inputs (GET, or POST without a +/// `Parameters` wrapper). +#[derive(Debug, Default, Deserialize)] +pub struct ValidateQuery { + mode: Option, + profile: Option, +} + +/// The operation inputs after unwrapping body/query. +struct ValidateInputs { + resource: Option, + mode: ValidateMode, + profiles: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ValidateMode { + General, + Create, + Update, + Delete, + Profile, +} + +impl ValidateMode { + fn parse(raw: Option<&str>) -> Result { + match raw { + None => Ok(ValidateMode::General), + Some("create") => Ok(ValidateMode::Create), + Some("update") => Ok(ValidateMode::Update), + Some("delete") => Ok(ValidateMode::Delete), + Some("profile") => Ok(ValidateMode::Profile), + Some(other) => Err(RestError::BadRequest { + message: format!( + "Invalid $validate mode '{other}' (expected create | update | delete | profile)" + ), + }), + } + } +} + +/// Unwrap the request body: either a raw resource, or a `Parameters` +/// carrying `resource` / `mode` / `profile` parts. Query parameters fill in +/// whatever the body did not provide. +fn unwrap_inputs(body: Option, query: &ValidateQuery) -> Result { + let mut resource = None; + let mut mode_raw: Option = None; + let mut profiles: Vec = Vec::new(); + + if let Some(body) = body { + if body.get("resourceType").and_then(Value::as_str) == Some("Parameters") { + for param in body + .get("parameter") + .and_then(Value::as_array) + .map(|a| a.as_slice()) + .unwrap_or_default() + { + match param.get("name").and_then(Value::as_str) { + Some("resource") => { + resource = param.get("resource").cloned(); + } + Some("mode") => { + mode_raw = param + .get("valueCode") + .or_else(|| param.get("valueString")) + .and_then(Value::as_str) + .map(str::to_string); + } + Some("profile") => { + if let Some(p) = param + .get("valueUri") + .or_else(|| param.get("valueCanonical")) + .or_else(|| param.get("valueString")) + .and_then(Value::as_str) + { + profiles.push(p.to_string()); + } + } + _ => {} + } + } + } else { + resource = Some(body); + } + } + + if mode_raw.is_none() { + mode_raw = query.mode.clone(); + } + if let Some(p) = &query.profile + && !profiles.iter().any(|existing| existing == p) + { + profiles.push(p.clone()); + } + + let mode = ValidateMode::parse(mode_raw.as_deref())?; + if mode == ValidateMode::Profile && profiles.is_empty() { + return Err(RestError::BadRequest { + message: "$validate mode=profile requires a profile parameter".to_string(), + }); + } + + Ok(ValidateInputs { resource, mode, profiles }) +} + +/// Shared tail: run validation and shape the 200 OperationOutcome response. +async fn respond( + state: &AppState, + fhir_version: helios_fhir::FhirVersion, + inputs: ValidateInputs, + resource_type: &str, + tenant_id: &str, + req_headers: &HeaderMap, +) -> RestResult +where + S: ResourceStorage + Send + Sync, +{ + let negotiated = negotiate_format(req_headers, None); + + // Delete validation: no content checks yet (no referential integrity). + if inputs.mode == ValidateMode::Delete { + let outcome = OperationOutcomeBuilder::new() + .information(IssueType::Informational, "Delete validation not performed: no checks configured") + .build(); + return format_resource_response(StatusCode::OK, HeaderMap::new(), &outcome, negotiated.format) + .map_err(|_| RestError::InternalError { + message: "Failed to serialize response".to_string(), + }); + } + + let Some(resource) = inputs.resource else { + return Err(RestError::BadRequest { + message: "$validate requires a resource (in the body or as the 'resource' parameter)" + .to_string(), + }); + }; + + // The resource must be of the addressed type. + if let Some(body_type) = resource.get("resourceType").and_then(Value::as_str) + && body_type != resource_type + { + return Err(RestError::BadRequest { + message: format!( + "Resource type in body ({body_type}) does not match URL ({resource_type})" + ), + }); + } + + let issues = state + .validation() + .validate_resource(fhir_version, &resource, inputs.profiles, Some(tenant_id)) + .await; + + debug!( + resource_type = %resource_type, + issue_count = issues.len(), + "$validate completed" + ); + + let outcome = validation_outcome(&issues); + format_resource_response(StatusCode::OK, HeaderMap::new(), &outcome, negotiated.format) + .map_err(|_| RestError::InternalError { message: "Failed to serialize response".to_string() }) +} + +/// `POST [base]/[type]/$validate` +pub async fn validate_type_handler( + State(state): State>, + Path(resource_type): Path, + tenant: TenantExtractor, + version: FhirVersionExtractor, + Query(query): Query, + req_headers: HeaderMap, + FhirResource(body): FhirResource, +) -> RestResult +where + S: ResourceStorage + Send + Sync, +{ + let inputs = unwrap_inputs(Some(body), &query)?; + respond( + &state, + version.storage_version(), + inputs, + &resource_type, + tenant.tenant_id(), + &req_headers, + ) + .await +} + +/// `GET [base]/[type]/[id]/$validate` — validate the stored resource. +pub async fn validate_instance_get_handler( + State(state): State>, + Path((resource_type, id)): Path<(String, String)>, + tenant: TenantExtractor, + version: FhirVersionExtractor, + Query(query): Query, + req_headers: HeaderMap, +) -> RestResult +where + S: ResourceStorage + Send + Sync, +{ + let stored = state + .storage() + .read(tenant.context(), &resource_type, &id) + .await? + .ok_or_else(|| RestError::NotFound { + resource_type: resource_type.clone(), + id: id.clone(), + })?; + + let mut inputs = unwrap_inputs(None, &query)?; + inputs.resource = Some(stored.content().clone()); + respond( + &state, + version.storage_version(), + inputs, + &resource_type, + tenant.tenant_id(), + &req_headers, + ) + .await +} + +/// `POST [base]/[type]/[id]/$validate` — validate the body in the context +/// of the addressed instance (update semantics). +pub async fn validate_instance_post_handler( + State(state): State>, + Path((resource_type, _id)): Path<(String, String)>, + tenant: TenantExtractor, + version: FhirVersionExtractor, + Query(query): Query, + req_headers: HeaderMap, + FhirResource(body): FhirResource, +) -> RestResult +where + S: ResourceStorage + Send + Sync, +{ + let inputs = unwrap_inputs(Some(body), &query)?; + respond( + &state, + version.storage_version(), + inputs, + &resource_type, + tenant.tenant_id(), + &req_headers, + ) + .await +} diff --git a/crates/rest/src/lib.rs b/crates/rest/src/lib.rs index d7660a75f..c1aa76af1 100644 --- a/crates/rest/src/lib.rs +++ b/crates/rest/src/lib.rs @@ -162,6 +162,7 @@ pub mod routing; pub mod state; pub mod tenant; pub mod terminology; +pub mod validation; // Re-export commonly used types pub use config::{MultitenancyConfig, ServerConfig, StorageBackendMode, TenantRoutingMode}; diff --git a/crates/rest/src/responses/operation_outcome.rs b/crates/rest/src/responses/operation_outcome.rs index dda1f09d5..e521ae992 100644 --- a/crates/rest/src/responses/operation_outcome.rs +++ b/crates/rest/src/responses/operation_outcome.rs @@ -40,6 +40,10 @@ pub enum IssueType { Required, /// Value out of range. Value, + /// FHIRPath invariant violation. + Invariant, + /// Invalid code / failed terminology binding. + CodeInvalid, /// Resource not found. NotFound, /// Resource was deleted. @@ -78,6 +82,8 @@ impl IssueType { IssueType::Structure => "structure", IssueType::Required => "required", IssueType::Value => "value", + IssueType::Invariant => "invariant", + IssueType::CodeInvalid => "code-invalid", IssueType::NotFound => "not-found", IssueType::Deleted => "deleted", IssueType::MultipleMatches => "multiple-matches", diff --git a/crates/rest/src/routing/fhir_routes.rs b/crates/rest/src/routing/fhir_routes.rs index d0553aecf..e95b8fef7 100644 --- a/crates/rest/src/routing/fhir_routes.rs +++ b/crates/rest/src/routing/fhir_routes.rs @@ -292,6 +292,17 @@ where get(handlers::reindex_status_handler::) .delete(handlers::reindex_cancel_handler::), ) + // Resource validation ($validate) — operation routes precede the + // catch-all (matchit gives static segments priority over {id}). + .route( + "/{resource_type}/$validate", + post(handlers::validate_type_handler::), + ) + .route( + "/{resource_type}/{id}/$validate", + get(handlers::validate_instance_get_handler::) + .post(handlers::validate_instance_post_handler::), + ) // Type-level routes .route("/{resource_type}", get(handlers::search_get_handler::)) .route("/{resource_type}", post(handlers::create_handler::)) diff --git a/crates/rest/src/state.rs b/crates/rest/src/state.rs index e8607f5d7..f37e448b6 100644 --- a/crates/rest/src/state.rs +++ b/crates/rest/src/state.rs @@ -116,6 +116,10 @@ pub struct AppState { /// Bulk submit configuration. bulk_submit_config: Arc, + + /// Resource validation service ($validate + optional write-path + /// enforcement). Always present; write-path behavior is config-gated. + validation: Arc, } // Manually implement Clone since S is wrapped in Arc and doesn't need to be Clone @@ -144,6 +148,7 @@ impl Clone for AppState { bulk_submit_output: self.bulk_submit_output.clone(), bulk_submit_file_auth: self.bulk_submit_file_auth.clone(), bulk_submit_config: Arc::clone(&self.bulk_submit_config), + validation: Arc::clone(&self.validation), } } } @@ -158,6 +163,10 @@ 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(), + )); Self { storage, config: Arc::new(config), @@ -181,6 +190,7 @@ impl AppState { bulk_submit_output: None, bulk_submit_file_auth: None, bulk_submit_config, + validation, } } @@ -205,6 +215,10 @@ 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(), + )); Self { storage, config: Arc::new(config), @@ -228,9 +242,25 @@ impl AppState { bulk_submit_output: None, bulk_submit_file_auth: None, bulk_submit_config, + validation, } } + /// Replaces the validation service (e.g. one configured with a + /// terminology provider or non-default constraint suppression). + pub fn with_validation( + mut self, + validation: Arc, + ) -> Self { + self.validation = validation; + self + } + + /// Returns the resource validation service. + pub fn validation(&self) -> &crate::validation::ValidationService { + &self.validation + } + /// Sets the SQL-on-FHIR runner for this application state. /// /// Typically called at startup after creating the state, once the runner has been diff --git a/crates/rest/src/validation.rs b/crates/rest/src/validation.rs new file mode 100644 index 000000000..066b00ee8 --- /dev/null +++ b/crates/rest/src/validation.rs @@ -0,0 +1,439 @@ +//! Resource validation service: the REST layer's bridge to +//! `helios-fhir-validator`. +//! +//! Owns the per-version core validators (embedded schema packs), the +//! FHIRPath constraint evaluator, the optional terminology provider, +//! per-tenant profile registries fed from stored StructureDefinitions, and +//! the mapping from validator issues onto FHIR `OperationOutcome` issues. +//! +//! The `$validate` operation uses [`ValidationService::validate_resource`] +//! unconditionally; the write path (create/update/batch) goes through +//! [`ValidationService::check_write`], which is gated by +//! `HFS_VALIDATION_MODE` (`off` | `log` | `enforce`). + +use crate::config::ValidationConfig; +use crate::error::RestError; +use crate::responses::operation_outcome::{ + Issue, IssueSeverity, IssueType, OperationOutcomeBuilder, +}; +use async_trait::async_trait; +use dashmap::DashMap; +use helios_fhir::FhirVersion; +use helios_fhir_validator::fhirpath_effects::FhirPathConstraintEvaluator; +use helios_fhir_validator::{ + dotted_to_fhirpath, CodedValue, CompositeResolver, EffectHandlers, ErrorKind, SchemaRegistry, + SchemaResolver, Severity, TerminologyError, TerminologyProvider, UnknownProfilePolicy, + ValidationError, ValidationOptions, Validator, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tracing::{debug, warn}; + +/// Per-tenant, per-version profile registries fed from stored +/// StructureDefinitions. +type TenantProfileMap = DashMap<(String, FhirVersion), Arc>>; + +/// Write-path behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ValidationMode { + /// Skip write-path validation entirely. + #[default] + Off, + /// Validate, log issues, and proceed. + Log, + /// Reject invalid resources with `422 Unprocessable Entity`. + Enforce, +} + +/// The validation service. Cheap to construct (core registries load lazily, +/// once per process); hold one in `AppState` for the AST cache to pay off. +pub struct ValidationService { + mode: ValidationMode, + constraint_evaluator: Option, + terminology: Option>, + /// Constraint ids never evaluated (default: `dom-6`, the narrative + /// warning that fires on almost every machine-generated resource). + suppress_constraints: Vec, + /// Escalate terminology-service failures to errors. + terminology_fail_closed: bool, + /// Validate against `meta.profile` claims. + use_meta_profiles: bool, + unknown_profile: UnknownProfilePolicy, + /// Per-tenant profile overlays, fed from stored StructureDefinition + /// writes. `None` disables the feature. + tenant_profiles: Option, +} + +impl Default for ValidationService { + fn default() -> Self { + Self { + mode: ValidationMode::Off, + constraint_evaluator: Some(FhirPathConstraintEvaluator::new()), + terminology: None, + suppress_constraints: vec!["dom-6".to_string()], + terminology_fail_closed: false, + use_meta_profiles: true, + unknown_profile: UnknownProfilePolicy::Warn, + tenant_profiles: Some(DashMap::new()), + } + } +} + +impl ValidationService { + /// A service with the default posture: write path off, constraints on + /// (dom-6 suppressed), meta.profile honored, unresolvable profiles + /// warned, stored profiles on, no terminology provider. + pub fn new() -> Self { + Self::default() + } + + /// Build a service from `HFS_VALIDATION_*` configuration. + /// `terminology_server` is `HFS_TERMINOLOGY_SERVER` (required for + /// `terminology = remote`; the config validator enforces the pairing). + pub fn from_config(config: &ValidationConfig, terminology_server: Option<&str>) -> Self { + let mode = match config.mode.as_str() { + "log" => ValidationMode::Log, + "enforce" => ValidationMode::Enforce, + _ => ValidationMode::Off, + }; + let unknown_profile = match config.unknown_profile.as_str() { + "error" => UnknownProfilePolicy::Error, + "ignore" => UnknownProfilePolicy::Ignore, + _ => UnknownProfilePolicy::Warn, + }; + let terminology: Option> = + match (config.terminology.as_str(), terminology_server) { + ("remote", Some(base)) => Some(Arc::new(RemoteTerminologyProvider::new( + base.to_string(), + Duration::from_millis(config.terminology_timeout_ms), + ))), + _ => None, + }; + Self { + mode, + constraint_evaluator: config.constraints.then(FhirPathConstraintEvaluator::new), + terminology, + suppress_constraints: config.suppress_constraints.clone(), + terminology_fail_closed: config.terminology_fail == "closed", + use_meta_profiles: config.meta_profiles, + unknown_profile, + tenant_profiles: config.stored_profiles.then(DashMap::new), + } + } + + /// Replace the terminology provider (bindings stay unchecked without one). + pub fn with_terminology(mut self, provider: Arc) -> Self { + self.terminology = Some(provider); + self + } + + /// The configured write-path mode. + pub fn mode(&self) -> ValidationMode { + self.mode + } + + /// 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. + pub async fn validate_resource( + &self, + version: FhirVersion, + resource: &Value, + profiles: Vec, + tenant: Option<&str>, + ) -> Vec { + let core = helios_fhir_validator::packs::core_registry(version); + let resolver: Arc = match self.tenant_overlay(tenant, version) { + Some(overlay) => Arc::new(CompositeResolver::new(vec![overlay, core])), + None => core, + }; + let validator = Validator::new(resolver); + let opts = ValidationOptions { + profiles, + use_meta_profiles: self.use_meta_profiles, + unknown_profile: self.unknown_profile, + }; + let handlers = EffectHandlers { + constraints: self + .constraint_evaluator + .as_ref() + .map(|e| e as &dyn helios_fhir_validator::ConstraintEvaluator), + terminology: self.terminology.as_deref(), + suppress_constraints: &self.suppress_constraints, + terminology_fail_closed: self.terminology_fail_closed, + }; + validator.validate(resource, version, &opts, &handlers).await + } + + /// Write-path gate. `Ok(())` = proceed with the write; `Err` = reject + /// (enforce mode with error-severity issues → `422` carrying the full + /// OperationOutcome). + pub async fn check_write( + &self, + tenant: &str, + version: FhirVersion, + resource_type: &str, + resource: &Value, + ) -> Result<(), RestError> { + if self.mode == ValidationMode::Off { + return Ok(()); + } + let issues = self + .validate_resource(version, resource, Vec::new(), Some(tenant)) + .await; + if issues.is_empty() { + return Ok(()); + } + match self.mode { + ValidationMode::Off => Ok(()), + ValidationMode::Log => { + for issue in &issues { + warn!( + tenant = %tenant, + resource_type = %resource_type, + path = %issue.path, + kind = ?issue.kind, + "validation (log mode): {}", + issue.message + ); + } + Ok(()) + } + ValidationMode::Enforce => { + if has_errors(&issues) { + debug!( + tenant = %tenant, + resource_type = %resource_type, + issues = issues.len(), + "rejecting write: validation failed" + ); + Err(RestError::ValidationFailed { outcome: validation_outcome(&issues) }) + } else { + // Warnings only: proceed, but leave a trace. + for issue in &issues { + warn!( + tenant = %tenant, + resource_type = %resource_type, + path = %issue.path, + "validation warning on write: {}", + issue.message + ); + } + Ok(()) + } + } + } + } + + /// Fold a stored StructureDefinition into the tenant's profile registry + /// (called after successful StructureDefinition writes). Conversion + /// failures are logged, never fatal — the write itself already + /// succeeded. + pub fn upsert_stored_profile(&self, tenant: &str, version: FhirVersion, sd: &Value) { + let Some(registries) = &self.tenant_profiles else { + return; + }; + match helios_fhir_validator::converter::convert(sd) { + Ok(conversion) => { + for w in &conversion.warnings { + warn!(tenant = %tenant, "profile conversion warning: {w}"); + } + let registry = registries + .entry((tenant.to_string(), version)) + .or_insert_with(|| Arc::new(RwLock::new(SchemaRegistry::new()))) + .clone(); + let inserted = registry + .write() + .expect("tenant profile registry lock") + .insert(conversion.schema); + if inserted { + debug!(tenant = %tenant, url = ?sd.get("url"), "tenant profile registered"); + } else { + warn!(tenant = %tenant, "stored StructureDefinition has neither url nor name; not registered"); + } + } + Err(e) => { + warn!(tenant = %tenant, "stored StructureDefinition failed to convert: {e}"); + } + } + } + + fn tenant_overlay( + &self, + tenant: Option<&str>, + version: FhirVersion, + ) -> Option> { + let registries = self.tenant_profiles.as_ref()?; + let tenant = tenant?; + let registry = registries.get(&(tenant.to_string(), version))?.clone(); + Some(Arc::new(LockedRegistryResolver(registry))) + } +} + +/// Resolver adapter over a shared, mutable registry. +struct LockedRegistryResolver(Arc>); + +impl SchemaResolver for LockedRegistryResolver { + fn resolve(&self, reference: &str) -> Option> { + self.0.read().expect("tenant profile registry lock").resolve(reference) + } +} + +// --------------------------------------------------------------------- +// Remote terminology provider +// --------------------------------------------------------------------- + +/// `TerminologyProvider` backed by a FHIR terminology server's +/// `ValueSet/$validate-code`, with a small in-memory TTL cache (neither the +/// fhirpath nor the search terminology clients cache). +pub struct RemoteTerminologyProvider { + base_url: String, + client: reqwest::Client, + /// `(valueSet, coded-token)` → validity, cached for [`CACHE_TTL`]. + cache: DashMap, +} + +/// How long `$validate-code` verdicts are cached. +const CACHE_TTL: Duration = Duration::from_secs(300); + +impl RemoteTerminologyProvider { + /// `base_url` is the terminology server root (e.g. `http://hts:8090`). + pub fn new(base_url: String, timeout: Duration) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("reqwest client builds"), + cache: DashMap::new(), + } + } + + fn payload(value_set: &str, coded: &CodedValue) -> Value { + let mut parameter = vec![json!({ "name": "url", "valueUri": value_set })]; + match coded { + CodedValue::Code(code) => { + parameter.push(json!({ "name": "inferSystem", "valueBoolean": true })); + parameter.push(json!({ "name": "code", "valueCode": code })); + } + CodedValue::Coding(coding) => { + parameter.push(json!({ "name": "coding", "valueCoding": coding })); + } + CodedValue::CodeableConcept(concept) => { + parameter + .push(json!({ "name": "codeableConcept", "valueCodeableConcept": concept })); + } + } + json!({ "resourceType": "Parameters", "parameter": parameter }) + } +} + +#[async_trait] +impl TerminologyProvider for RemoteTerminologyProvider { + async fn validate_code( + &self, + value_set: &str, + coded: &CodedValue, + ) -> Result { + let key = format!("{value_set}|{coded:?}"); + if let Some(entry) = self.cache.get(&key) { + let (verdict, at) = *entry; + if at.elapsed() < CACHE_TTL { + return Ok(verdict); + } + } + + let url = format!("{}/ValueSet/$validate-code", self.base_url); + let response = self + .client + .post(&url) + .json(&Self::payload(value_set, coded)) + .send() + .await + .map_err(|e| TerminologyError(format!("request to {url} failed: {e}")))?; + if !response.status().is_success() { + return Err(TerminologyError(format!( + "{url} returned {}", + response.status() + ))); + } + let body: Value = response + .json() + .await + .map_err(|e| TerminologyError(format!("invalid $validate-code response: {e}")))?; + let verdict = body + .get("parameter") + .and_then(Value::as_array) + .and_then(|params| { + params + .iter() + .find(|p| p.get("name").and_then(Value::as_str) == Some("result")) + }) + .and_then(|p| p.get("valueBoolean")) + .and_then(Value::as_bool) + .ok_or_else(|| { + TerminologyError("no boolean 'result' parameter in response".to_string()) + })?; + + self.cache.insert(key, (verdict, Instant::now())); + Ok(verdict) + } +} + +// --------------------------------------------------------------------- +// OperationOutcome mapping +// --------------------------------------------------------------------- + +/// Map one validator issue onto an OperationOutcome issue. +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::FhirpathConstraint => IssueType::Invariant, + ErrorKind::TerminologyBinding => IssueType::CodeInvalid, + ErrorKind::UnknownSchema | ErrorKind::UnknownProfile => IssueType::NotSupported, + // Everything structural: unknown-element, shape, cardinality, + // slicing, choices, wrong container type. + ErrorKind::UnknownElement + | ErrorKind::NotArray + | ErrorKind::NotSingular + | ErrorKind::Type + | ErrorKind::Excluded + | ErrorKind::Min + | ErrorKind::Max + | ErrorKind::SliceCardinality + | ErrorKind::SliceUnmatched + | ErrorKind::SliceOrder + | ErrorKind::Choice + | ErrorKind::ChoiceExcluded => IssueType::Structure, + }; + let severity = match error.severity { + Severity::Error => IssueSeverity::Error, + Severity::Warning => IssueSeverity::Warning, + }; + Issue::new(severity, code, error.message.clone()) + .with_expression(dotted_to_fhirpath(&error.path)) +} + +/// Build the `$validate` OperationOutcome: the mapped issues, or the +/// canonical all-clear information issue when there are none. +pub fn validation_outcome(errors: &[ValidationError]) -> Value { + let mut builder = OperationOutcomeBuilder::new(); + if errors.is_empty() { + builder = builder.information(IssueType::Informational, "Validation successful"); + } else { + for error in errors { + builder = builder.add_issue(to_outcome_issue(error)); + } + } + builder.build() +} + +/// Whether any issue is error severity (drives `$validate` reporting and +/// enforce-mode rejection). +pub fn has_errors(errors: &[ValidationError]) -> bool { + errors.iter().any(|e| e.severity == Severity::Error) +} diff --git a/crates/rest/tests/validate_operation_tests.rs b/crates/rest/tests/validate_operation_tests.rs new file mode 100644 index 000000000..6f0f923ca --- /dev/null +++ b/crates/rest/tests/validate_operation_tests.rs @@ -0,0 +1,302 @@ +//! Integration tests for the `$validate` operation. +//! +//! `POST [base]/[type]/$validate` (raw resource or Parameters wrapper), +//! `GET/POST [base]/[type]/[id]/$validate`, mode/profile handling, and the +//! always-200-OperationOutcome contract. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum_test::TestServer; +use helios_fhir::FhirVersion; +use helios_persistence::backends::sqlite::{SqliteBackend, SqliteBackendConfig}; +use helios_persistence::core::ResourceStorage; +use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions}; +use helios_rest::{MultitenancyConfig, ServerConfig, TenantRoutingMode}; +use serde_json::{json, Value}; + +async fn create_test_server() -> (TestServer, Arc) { + let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("data")) + .unwrap_or_else(|| PathBuf::from("data")); + + let backend_config = SqliteBackendConfig { data_dir: Some(data_dir), ..Default::default() }; + let backend = SqliteBackend::with_config(":memory:", backend_config) + .expect("Failed to create SQLite backend"); + backend.init_schema().expect("Failed to init schema"); + let backend = Arc::new(backend); + + let config = ServerConfig { + multitenancy: MultitenancyConfig { + routing_mode: TenantRoutingMode::HeaderOnly, + ..Default::default() + }, + base_url: "http://localhost:8080".to_string(), + default_tenant: "test-tenant".to_string(), + ..ServerConfig::for_testing() + }; + + let state = helios_rest::AppState::new(Arc::clone(&backend), config); + let app = helios_rest::routing::fhir_routes::create_routes(state); + let server = TestServer::new(app).expect("Failed to create test server"); + (server, backend) +} + +fn test_tenant() -> TenantContext { + TenantContext::new(TenantId::new("test-tenant"), TenantPermissions::full_access()) +} + +fn issue_codes(outcome: &Value) -> Vec<(String, String)> { + outcome["issue"] + .as_array() + .map(|issues| { + issues + .iter() + .map(|i| { + ( + i["severity"].as_str().unwrap_or_default().to_string(), + i["code"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() + }) + .unwrap_or_default() +} + +#[tokio::test] +async fn validate_valid_patient_returns_all_clear() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate") + .json(&json!({ + "resourceType": "Patient", + "name": [{ "family": "Smith", "given": ["Jan"] }], + "gender": "female", + "birthDate": "1980-02-29" + })) + .await; + + response.assert_status_ok(); + let outcome: Value = response.json(); + assert_eq!(outcome["resourceType"], "OperationOutcome"); + assert_eq!( + issue_codes(&outcome), + vec![("information".to_string(), "informational".to_string())], + "clean validation reports the all-clear issue: {outcome:#}" + ); +} + +#[tokio::test] +async fn validate_reports_structural_issues_with_expressions() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate") + .json(&json!({ + "resourceType": "Patient", + "bogusElement": true, + "gender": ["male"], + "name": { "family": "NotAnArray" } + })) + .await; + + // Invalid resource is still a SUCCESSFUL validation: 200 + issues. + response.assert_status_ok(); + let outcome: Value = response.json(); + let issues = outcome["issue"].as_array().expect("issues"); + assert!( + issues.iter().any(|i| i["code"] == "structure" + && i["expression"][0] == "Patient.bogusElement"), + "unknown element issue with FHIRPath expression expected: {outcome:#}" + ); + assert!( + issues.iter().any(|i| i["expression"][0] == "Patient.gender"), + "not-singular issue on gender expected: {outcome:#}" + ); + assert!( + issues.iter().any(|i| i["expression"][0] == "Patient.name"), + "not-array issue on name expected: {outcome:#}" + ); +} + +#[tokio::test] +async fn validate_accepts_parameters_wrapper() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate") + .json(&json!({ + "resourceType": "Parameters", + "parameter": [ + { "name": "mode", "valueCode": "create" }, + { "name": "resource", "resource": { "resourceType": "Patient", "active": true } } + ] + })) + .await; + + response.assert_status_ok(); + let outcome: Value = response.json(); + assert_eq!( + issue_codes(&outcome), + vec![("information".to_string(), "informational".to_string())], + "{outcome:#}" + ); +} + +#[tokio::test] +async fn validate_mode_delete_skips_content_validation() { + let (server, _backend) = create_test_server().await; + // Delete validation needs no resource at all. + let response = server + .post("/Patient/$validate?mode=delete") + .json(&json!({ + "resourceType": "Parameters", + "parameter": [] + })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert_eq!(outcome["issue"][0]["severity"], "information", "{outcome:#}"); +} + +#[tokio::test] +async fn validate_mode_profile_requires_profile() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate?mode=profile") + .json(&json!({ "resourceType": "Patient" })) + .await; + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn validate_rejects_unknown_mode() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate?mode=bogus") + .json(&json!({ "resourceType": "Patient" })) + .await; + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn validate_rejects_type_mismatch() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate") + .json(&json!({ "resourceType": "Observation", "status": "final" })) + .await; + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn validate_unknown_profile_reports_warning() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate?profile=http://example.org/StructureDefinition/nope") + .json(&json!({ "resourceType": "Patient" })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert!( + issue_codes(&outcome) + .iter() + .any(|(sev, code)| sev == "warning" && code == "not-supported"), + "unresolvable profile surfaces as a warning: {outcome:#}" + ); +} + +#[tokio::test] +async fn validate_instance_get_validates_stored_resource() { + let (server, backend) = create_test_server().await; + let tenant = test_tenant(); + backend + .create( + &tenant, + "Patient", + json!({ "resourceType": "Patient", "id": "p1", "active": true }), + FhirVersion::R4, + ) + .await + .expect("seed patient"); + + let response = server.get("/Patient/p1/$validate").await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert_eq!(outcome["resourceType"], "OperationOutcome", "{outcome:#}"); + assert!( + !issue_codes(&outcome).iter().any(|(sev, _)| sev == "error"), + "stored minimal patient must validate without errors: {outcome:#}" + ); +} + +#[tokio::test] +async fn validate_instance_get_unknown_id_is_404() { + let (server, _backend) = create_test_server().await; + let response = server.get("/Patient/does-not-exist/$validate").await; + response.assert_status_not_found(); +} + +#[tokio::test] +async fn validate_evaluates_real_fhirpath_invariants() { + // End-to-end: the default ValidationService carries the FHIRPath + // constraint evaluator, so the real spec invariant pat-1 ("contact + // SHALL contain details or an organization reference") fires against + // the embedded R4 pack. + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/$validate") + .json(&json!({ + "resourceType": "Patient", + "contact": [{ "gender": "male" }] + })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + let issues = outcome["issue"].as_array().expect("issues"); + assert!( + issues.iter().any(|i| i["code"] == "invariant" + && i["severity"] == "error" + && i["expression"][0] == "Patient.contact[0]" + && i["details"]["text"].as_str().unwrap_or_default().contains("pat-1")), + "pat-1 invariant issue expected: {outcome:#}" + ); + + // Satisfying the invariant clears it. + let response = server + .post("/Patient/$validate") + .json(&json!({ + "resourceType": "Patient", + "contact": [{ "gender": "male", "name": { "family": "Smith" } }] + })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert!( + !outcome["issue"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "invariant" && i["severity"] == "error"), + "satisfied contact must not fire pat-1: {outcome:#}" + ); +} + +#[tokio::test] +async fn validate_instance_post_validates_body() { + let (server, _backend) = create_test_server().await; + let response = server + .post("/Patient/p1/$validate?mode=update") + .json(&json!({ "resourceType": "Patient", "id": "p1", "oops": 1 })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert!( + outcome["issue"] + .as_array() + .unwrap() + .iter() + .any(|i| i["expression"][0] == "Patient.oops"), + "{outcome:#}" + ); +} diff --git a/crates/rest/tests/validation_enforcement_tests.rs b/crates/rest/tests/validation_enforcement_tests.rs new file mode 100644 index 000000000..1d74acb90 --- /dev/null +++ b/crates/rest/tests/validation_enforcement_tests.rs @@ -0,0 +1,249 @@ +//! Write-path validation enforcement (`HFS_VALIDATION_MODE`) and +//! stored-profile (tenant StructureDefinition registry) integration tests. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum_test::TestServer; +use helios_persistence::backends::sqlite::{SqliteBackend, SqliteBackendConfig}; +use helios_rest::config::ValidationConfig; +use helios_rest::{MultitenancyConfig, ServerConfig, TenantRoutingMode}; +use serde_json::{json, Value}; + +async fn create_test_server(mode: &str) -> TestServer { + let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("data")) + .unwrap_or_else(|| PathBuf::from("data")); + + let backend_config = SqliteBackendConfig { data_dir: Some(data_dir), ..Default::default() }; + let backend = SqliteBackend::with_config(":memory:", backend_config) + .expect("Failed to create SQLite backend"); + backend.init_schema().expect("Failed to init schema"); + let backend = Arc::new(backend); + + let config = ServerConfig { + multitenancy: MultitenancyConfig { + routing_mode: TenantRoutingMode::HeaderOnly, + ..Default::default() + }, + base_url: "http://localhost:8080".to_string(), + default_tenant: "test-tenant".to_string(), + validation: ValidationConfig { mode: mode.to_string(), ..Default::default() }, + ..ServerConfig::for_testing() + }; + + let state = helios_rest::AppState::new(backend, config); + let app = helios_rest::routing::fhir_routes::create_routes(state); + TestServer::new(app).expect("Failed to create test server") +} + +fn invalid_patient() -> Value { + json!({ "resourceType": "Patient", "bogusElement": true }) +} + +fn valid_patient() -> Value { + json!({ "resourceType": "Patient", "active": true }) +} + +#[tokio::test] +async fn enforce_mode_rejects_invalid_writes_with_outcome() { + let server = create_test_server("enforce").await; + + let response = server.post("/Patient").json(&invalid_patient()).await; + response.assert_status_unprocessable_entity(); + let outcome: Value = response.json(); + assert_eq!(outcome["resourceType"], "OperationOutcome", "{outcome:#}"); + assert!( + outcome["issue"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "structure" && i["expression"][0] == "Patient.bogusElement"), + "expected the structural issue in the 422 body: {outcome:#}" + ); + + // Updates are enforced too. + let response = server + .put("/Patient/p1") + .json(&json!({ "resourceType": "Patient", "id": "p1", "bogusElement": true })) + .await; + response.assert_status_unprocessable_entity(); +} + +#[tokio::test] +async fn enforce_mode_allows_valid_writes() { + let server = create_test_server("enforce").await; + let response = server.post("/Patient").json(&valid_patient()).await; + assert_eq!(response.status_code(), 201, "{}", response.text()); +} + +#[tokio::test] +async fn log_and_off_modes_do_not_reject() { + for mode in ["log", "off"] { + let server = create_test_server(mode).await; + let response = server.post("/Patient").json(&invalid_patient()).await; + assert_eq!( + response.status_code(), + 201, + "mode={mode} must not reject: {}", + response.text() + ); + } +} + +#[tokio::test] +async fn enforce_mode_rejects_invalid_batch_entries_individually() { + let server = create_test_server("enforce").await; + let response = server + .post("/") + .json(&json!({ + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "request": { "method": "POST", "url": "Patient" }, + "resource": invalid_patient() + }, + { + "request": { "method": "POST", "url": "Patient" }, + "resource": valid_patient() + } + ] + })) + .await; + response.assert_status_ok(); + let bundle: Value = response.json(); + let entries = bundle["entry"].as_array().expect("entries"); + assert_eq!(entries.len(), 2); + assert!( + entries[0]["response"]["status"] + .as_str() + .unwrap_or_default() + .starts_with("422"), + "first entry must fail validation: {bundle:#}" + ); + assert!( + entries[1]["response"]["status"] + .as_str() + .unwrap_or_default() + .starts_with("201"), + "second entry must succeed: {bundle:#}" + ); +} + +#[tokio::test] +async fn stored_profile_registers_and_validates() { + let server = create_test_server("off").await; + + // Upload a differential profile constraining Patient: birthDate 1..1, + // gender prohibited. + let profile = json!({ + "resourceType": "StructureDefinition", + "id": "strict-patient", + "url": "http://example.org/StructureDefinition/strict-patient", + "name": "StrictPatient", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Patient", "path": "Patient" }, + { "id": "Patient.birthDate", "path": "Patient.birthDate", "min": 1 }, + { "id": "Patient.gender", "path": "Patient.gender", "max": "0" } + ] + } + }); + let response = server.put("/StructureDefinition/strict-patient").json(&profile).await; + assert!( + response.status_code() == 201 || response.status_code() == 200, + "profile upload failed: {}", + response.text() + ); + + // $validate against the stored profile: violations reported. + let response = server + .post("/Patient/$validate?profile=http://example.org/StructureDefinition/strict-patient") + .json(&json!({ "resourceType": "Patient", "gender": "male" })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + let issues = outcome["issue"].as_array().expect("issues"); + assert!( + issues.iter().any(|i| i["code"] == "required" + && i["expression"][0] == "Patient.birthDate"), + "profile-required birthDate must be reported: {outcome:#}" + ); + assert!( + issues.iter().any(|i| i["code"] == "structure" + && i["expression"][0] == "Patient.gender"), + "profile-excluded gender must be reported: {outcome:#}" + ); + + // A conforming resource passes. + let response = server + .post("/Patient/$validate?profile=http://example.org/StructureDefinition/strict-patient") + .json(&json!({ "resourceType": "Patient", "birthDate": "1980-01-01" })) + .await; + response.assert_status_ok(); + let outcome: Value = response.json(); + assert_eq!( + outcome["issue"][0]["severity"], "information", + "conforming resource must validate clean: {outcome:#}" + ); +} + +#[tokio::test] +async fn enforce_mode_honors_meta_profile_claims() { + let server = create_test_server("enforce").await; + + let profile = json!({ + "resourceType": "StructureDefinition", + "id": "must-have-birthdate", + "url": "http://example.org/StructureDefinition/must-have-birthdate", + "name": "MustHaveBirthDate", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Patient", "path": "Patient" }, + { "id": "Patient.birthDate", "path": "Patient.birthDate", "min": 1 } + ] + } + }); + let response = server.put("/StructureDefinition/must-have-birthdate").json(&profile).await; + assert!( + response.status_code().is_success(), + "profile upload failed: {}", + response.text() + ); + + // A write claiming the profile but violating it is rejected. + let response = server + .post("/Patient") + .json(&json!({ + "resourceType": "Patient", + "meta": { "profile": ["http://example.org/StructureDefinition/must-have-birthdate"] } + })) + .await; + response.assert_status_unprocessable_entity(); + + // Satisfying the claimed profile passes. + let response = server + .post("/Patient") + .json(&json!({ + "resourceType": "Patient", + "meta": { "profile": ["http://example.org/StructureDefinition/must-have-birthdate"] }, + "birthDate": "1980-01-01" + })) + .await; + assert_eq!(response.status_code(), 201, "{}", response.text()); +}