#![expect(
clippy::disallowed_types,
reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
(ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
(#1694)"
)]
pub mod terminology;
use serde_json::Value;
use std::fmt::Write as _;
use openehr_rm::v1_2::model::declared_concrete_type;
use openehr_rm::v1_2::validate::{
check_archetyped_valid, check_cluster_items_present, check_data_structure_shapes,
check_mandatory_containers,
};
use crate::flat::webtemplate::model::WebTemplate;
use openehr_its::wire_validate::validate_rm_invariants_as;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationMessage {
pub path: String,
pub message: String,
pub kind: ValidationKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationKind {
WrongType,
Required,
Occurrences,
Cardinality,
RangeError,
PatternError,
CodedValue,
Terminology,
Invariant,
Unexpected,
Other,
}
pub(crate) fn push(
out: &mut Vec<ValidationMessage>,
path: impl Into<String>,
message: impl Into<String>,
kind: ValidationKind,
) {
out.push(ValidationMessage {
path: path.into(),
message: message.into(),
kind,
});
}
pub(crate) fn norm_path(p: &str) -> String {
if p.is_empty() {
"/".to_owned()
} else {
p.to_owned()
}
}
#[must_use]
pub fn validate_composition(composition: &Value, wt: &WebTemplate) -> Vec<ValidationMessage> {
let mut out = validate_rm_and_terminology(composition, "COMPOSITION");
out.extend(crate::flat::validation::validate_archetype_conformance(
composition,
wt,
));
out
}
#[must_use]
pub fn validate_party(party: &Value, wt: &WebTemplate) -> Vec<ValidationMessage> {
let root = wt
.tree
.rm_type
.split('<')
.next()
.unwrap_or(&wt.tree.rm_type);
if !crate::flat::build::PARTY_CLASSES.contains(&root) {
let mut out = Vec::new();
push(
&mut out,
"/",
format!(
"template {} is rooted at {root}, which is not a party class",
wt.template_id
),
ValidationKind::WrongType,
);
return out;
}
let mut out = validate_rm_and_terminology(party, root);
out.extend(crate::flat::validation::validate_archetype_conformance(
party, wt,
));
out
}
#[must_use]
pub fn validate_rm_and_terminology(root: &Value, declared: &str) -> Vec<ValidationMessage> {
validate_with(root, declared, LowerBounds::Enforced)
}
#[must_use]
pub fn validate_rm_and_terminology_incomplete_as(
root: &Value,
declared: &str,
) -> Vec<ValidationMessage> {
validate_with(root, declared, LowerBounds::Relaxed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LowerBounds {
Enforced,
Relaxed,
}
fn validate_with(root: &Value, declared: &str, bounds: LowerBounds) -> Vec<ValidationMessage> {
let mut out = Vec::new();
if let Some(wire) = root.get("_type").and_then(Value::as_str)
&& openehr_rm::v1_2::model::class(declared).is_some()
&& !openehr_rm::v1_2::model::is_a(wire, declared)
{
push(
&mut out,
"/".to_owned(),
format!(
"does not conform to RM type {declared}: the root claims `_type` \
{wire}, which is not a {declared}"
),
ValidationKind::Invariant,
);
}
let mut path = String::new();
rm_invariant_pass(&mut out, root, &mut path, Some(declared), bounds);
terminology::terminology_pass(&mut out, root, &mut path, Some(declared));
out
}
pub(crate) fn rm_invariant_pass(
out: &mut Vec<ValidationMessage>,
v: &Value,
path: &mut String,
declared: Option<&str>,
bounds: LowerBounds,
) {
let Some(obj) = v.as_object() else { return };
let fields = NodeFields::of(obj);
let (node_id, has_archetype_details, details_archetype_id) = (
fields.node_id,
fields.has_archetype_details,
fields.details_archetype_id,
);
let ty = fields.ty.or(declared);
let mut inv = Vec::new();
if let Some(effective) = ty {
match bounds {
LowerBounds::Enforced => validate_rm_invariants_as(effective, v, &mut inv),
LowerBounds::Relaxed => {
openehr_its::wire_validate::validate_rm_invariants_relaxed_as(
effective, v, &mut inv,
);
}
}
if bounds == LowerBounds::Enforced {
check_mandatory_containers(effective, v, &mut inv);
}
}
inv.extend(check_archetyped_valid(
node_id,
has_archetype_details,
details_archetype_id,
));
if bounds == LowerBounds::Enforced {
inv.extend(check_cluster_items_present(obj, ty));
}
inv.extend(check_data_structure_shapes(obj, ty));
for iv in inv {
let p = if iv.path.is_empty() {
norm_path(path)
} else {
format!("{}/{}", path.trim_end_matches('/'), iv.path)
};
push(out, p, iv.message, ValidationKind::Invariant);
}
for (k, val) in obj {
if !k.starts_with('_') {
descend_attribute(out, k, val, path, ty, bounds);
}
}
}
struct NodeFields<'a> {
ty: Option<&'a str>,
node_id: Option<&'a str>,
has_archetype_details: bool,
details_archetype_id: Option<&'a str>,
}
impl<'a> NodeFields<'a> {
fn of(obj: &'a serde_json::Map<String, Value>) -> Self {
let mut fields = NodeFields {
ty: None,
node_id: None,
has_archetype_details: false,
details_archetype_id: None,
};
for (k, val) in obj {
match k.as_str() {
"_type" => fields.ty = val.as_str(),
"archetype_node_id" => fields.node_id = val.as_str(),
"archetype_details" => {
fields.has_archetype_details = !val.is_null();
fields.details_archetype_id = val
.get("archetype_id")
.and_then(|a| a.get("value"))
.and_then(Value::as_str);
}
_ => {}
}
}
fields
}
}
fn descend_attribute(
out: &mut Vec<ValidationMessage>,
k: &str,
val: &Value,
path: &mut String,
ty: Option<&str>,
bounds: LowerBounds,
) {
let child_declared = ty.and_then(|t| declared_concrete_type(t, k));
match val {
Value::Array(a) => {
for (i, item) in a.iter().enumerate() {
let base = path.len();
let _ = write!(path, "/{k}[{i}]");
if item.is_object() {
check_slot_type(out, ty, k, item, path);
rm_invariant_pass(out, item, path, child_declared, bounds);
} else if let Some(iv) = ty.and_then(|parent| {
openehr_rm::v1_2::validate::check_slot_member_is_object(parent, k)
}) {
push(out, norm_path(path), iv.message, ValidationKind::Invariant);
}
path.truncate(base);
}
}
Value::Object(_) => {
let base = path.len();
let _ = write!(path, "/{k}");
check_slot_type(out, ty, k, val, path);
rm_invariant_pass(out, val, path, child_declared, bounds);
path.truncate(base);
}
_ => {}
}
}
fn check_slot_type(
out: &mut Vec<ValidationMessage>,
parent_ty: Option<&str>,
attribute: &str,
item: &Value,
path: &str,
) {
if let (Some(parent), Some(wire)) = (parent_ty, item.get("_type").and_then(Value::as_str))
&& let Some(iv) =
openehr_rm::v1_2::validate::check_declared_slot_type(parent, attribute, wire)
{
push(out, norm_path(path), iv.message, ValidationKind::Invariant);
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
#[test]
fn foreign_type_in_a_list_slot_rejected() {
let inst = json!({
"_type": "COMPOSITION",
"content": [{ "_type": "DV_TEXT", "value": "not a content item" }]
});
let out = rm_only(&inst);
assert!(
out.iter().any(|m| m.path == "/content[0]"
&& m.message.contains("`content` is declared CONTENT_ITEM")),
"the foreign list member must be refused at its own path: {out:?}"
);
}
#[test]
fn foreign_type_in_a_single_slot_rejected() {
let inst = json!({
"_type": "COMPOSITION",
"context": { "_type": "DV_TEXT", "value": "not a context" }
});
let out = rm_only(&inst);
assert!(
out.iter().any(|m| m.path == "/context"
&& m.message.contains("`context` is declared EVENT_CONTEXT")),
"the foreign single member must be refused at its own path: {out:?}"
);
}
#[test]
fn subtype_in_an_abstract_slot_accepted() {
let inst = json!({
"_type": "COMPOSITION",
"content": [{
"_type": "OBSERVATION",
"name": { "_type": "DV_TEXT", "value": "o" },
"archetype_node_id": "at0001"
}]
});
let out = rm_only(&inst);
assert!(
!out.iter()
.any(|m| m.message.contains("is declared CONTENT_ITEM")),
"an OBSERVATION is a CONTENT_ITEM — no slot violation: {out:?}"
);
}
#[test]
fn slot_type_conformance_survives_the_incomplete_relaxation() {
let inst = json!({
"_type": "COMPOSITION",
"content": [{ "_type": "DV_TEXT", "value": "still wrong" }]
});
let mut out = Vec::new();
rm_invariant_pass(
&mut out,
&inst,
&mut String::new(),
Some("COMPOSITION"),
LowerBounds::Relaxed,
);
assert!(
out.iter()
.any(|m| m.message.contains("`content` is declared CONTENT_ITEM")),
"the incomplete relaxation must not admit a wrong type: {out:?}"
);
}
fn rm_only(instance: &Value) -> Vec<ValidationMessage> {
let mut out = Vec::new();
rm_invariant_pass(
&mut out,
instance,
&mut String::new(),
Some("COMPOSITION"),
LowerBounds::Enforced,
);
out
}
fn root_node(node_id: &str, archetype_id: &str) -> Value {
json!({
"_type": "SECTION",
"name": { "_type": "DV_TEXT", "value": "s" },
"archetype_node_id": node_id,
"archetype_details": {
"_type": "ARCHETYPED",
"archetype_id": { "_type": "ARCHETYPE_ID", "value": archetype_id },
"rm_version": "1.2.0"
},
"items": [{
"_type": "OBSERVATION", "archetype_node_id": "at0001",
"name": { "_type": "DV_TEXT", "value": "o" }
}]
})
}
#[test]
fn archetype_root_node_id_mismatch_rejected() {
let inst = root_node(
"openEHR-EHR-SECTION.other.v1",
"openEHR-EHR-SECTION.adhoc.v1",
);
let msgs = rm_only(&inst);
assert!(
msgs.iter().any(|m| m.kind == ValidationKind::Invariant
&& m.message.contains("LOCATABLE.archetype_node_id")),
"expected a root node-id identity violation, got {msgs:?}"
);
}
#[test]
fn archetype_root_node_id_match_accepted() {
let inst = root_node(
"openEHR-EHR-SECTION.adhoc.v1",
"openEHR-EHR-SECTION.adhoc.v1",
);
let msgs = rm_only(&inst);
assert!(
!msgs
.iter()
.any(|m| m.message.contains("LOCATABLE.archetype_node_id")),
"a matching root must not violate the node-id identity rule: {msgs:?}"
);
}
#[test]
fn links_present_but_empty_rejected() {
let mut inst = root_node(
"openEHR-EHR-SECTION.adhoc.v1",
"openEHR-EHR-SECTION.adhoc.v1",
);
let obj = inst.as_object_mut().expect("root object");
obj.remove("items"); obj.insert("links".into(), json!([]));
let msgs = rm_only(&inst);
assert!(
msgs.iter()
.any(|m| m.message.contains("links") && m.message.contains("at least one member")),
"expected the links parse refusal, got {msgs:?}"
);
}
#[test]
fn links_non_empty_accepted() {
let mut inst = root_node(
"openEHR-EHR-SECTION.adhoc.v1",
"openEHR-EHR-SECTION.adhoc.v1",
);
inst.as_object_mut().expect("root object").insert(
"links".into(),
json!([{
"_type": "LINK",
"meaning": { "_type": "DV_TEXT", "value": "follow up" },
"type": { "_type": "DV_TEXT", "value": "issue" },
"target": { "_type": "DV_EHR_URI", "value": "ehr://example/x" }
}]),
);
let msgs = rm_only(&inst);
assert!(
!msgs.iter().any(|m| m.message.contains("Links_valid")),
"a non-empty links list must not violate Links_valid: {msgs:?}"
);
}
fn count_type_nodes(v: &Value) -> usize {
match v {
Value::Object(obj) => {
let self_count = usize::from(obj.contains_key("_type"));
self_count
+ obj
.iter()
.filter(|(k, _)| !k.starts_with('_'))
.map(|(_, val)| count_type_nodes(val))
.sum::<usize>()
}
Value::Array(a) => a.iter().map(count_type_nodes).sum(),
_ => 0,
}
}
fn time_pass(iters: u32, mut f: impl FnMut() -> usize) -> f64 {
let start = std::time::Instant::now();
let mut sink = 0usize;
for _ in 0..iters {
sink = sink.wrapping_add(f());
}
std::hint::black_box(sink);
start.elapsed().as_secs_f64() * 1e6 / f64::from(iters)
}
#[test]
#[ignore = "measurement, not a correctness gate — run with --run-ignored all"]
fn measure_ips_validation_walk_cost() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../corpus/templates/ckm/international-patient-summary.example.json"
);
let comp: Value =
serde_json::from_str(&std::fs::read_to_string(path).expect("read IPS example"))
.expect("parse IPS example");
let node_count = count_type_nodes(&comp);
for _ in 0..5 {
std::hint::black_box(validate_rm_and_terminology(&comp, "COMPOSITION").len());
}
let iters = 50;
let t_rm = time_pass(iters, || {
let mut out = Vec::new();
rm_invariant_pass(
&mut out,
&comp,
&mut String::new(),
Some("COMPOSITION"),
LowerBounds::Enforced,
);
out.len()
});
let t_term = time_pass(iters, || {
let mut out = Vec::new();
terminology::terminology_pass(&mut out, &comp, &mut String::new(), Some("COMPOSITION"));
out.len()
});
let t_both = time_pass(iters, || {
validate_rm_and_terminology(&comp, "COMPOSITION").len()
});
eprintln!("IPS validation walk cost ({node_count} _type nodes, {iters} iters):");
eprintln!(" pass 1 rm_invariant_pass : {t_rm:>8.1} us/op");
eprintln!(" pass 2 terminology_pass : {t_term:>8.1} us/op");
eprintln!(" combined (1+2) : {t_both:>8.1} us/op");
}
#[test]
#[ignore = "measurement, not a correctness gate — run with --run-ignored all"]
fn measure_ips_validation_full_cost() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/templates/ckm");
let opt_xml = std::fs::read_to_string(format!("{dir}/international-patient-summary.opt"))
.expect("read IPS OPT");
let opt = openehr_its::opt14::from_xml(&opt_xml).expect("parse IPS OPT");
let wt = crate::flat::webtemplate::builder::build_web_template(&opt)
.expect("build IPS WebTemplate");
let comp: Value = serde_json::from_str(
&std::fs::read_to_string(format!("{dir}/international-patient-summary.example.json"))
.expect("read IPS example"),
)
.expect("parse IPS example");
let node_count = count_type_nodes(&comp);
for _ in 0..5 {
std::hint::black_box(validate_composition(&comp, &wt).len());
}
let iters = 50;
let t_rmterm = time_pass(iters, || {
validate_rm_and_terminology(&comp, "COMPOSITION").len()
});
let t_walk = time_pass(iters, || {
crate::flat::validation::validate_archetype_conformance(&comp, &wt).len()
});
let t_all = time_pass(iters, || validate_composition(&comp, &wt).len());
eprintln!("IPS full validation cost ({node_count} _type nodes, {iters} iters):");
eprintln!(" passes 1+2 rm+terminology : {t_rmterm:>8.1} us/op");
eprintln!(" pass 3 walk (archetype conf.) : {t_walk:>8.1} us/op");
eprintln!(" full validate_composition : {t_all:>8.1} us/op");
}
}