Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/skills/run-hfs-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|-------------|
Expand All @@ -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. |
Expand All @@ -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
Expand All @@ -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

Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ default-members = [
"crates/fhir",
"crates/fhir-gen",
"crates/fhir-macro",
"crates/fhir-validator",
"crates/fhirpath",
"crates/fhirpath-support",
"crates/serde",
Expand Down Expand Up @@ -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"] }
58 changes: 58 additions & 0 deletions crates/fhir-validator/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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<T> — 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"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
141 changes: 141 additions & 0 deletions crates/fhir-validator/src/bin/generate_schema_packs.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String, String> {
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<Value> = Vec::new();
let mut sd_count = 0usize;
let mut warning_count = 0usize;
let mut failures: Vec<String> = 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("<no url>");
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
))
}
Loading
Loading