#![expect(
clippy::disallowed_types,
reason = "the wire-boundary validation reads the canonical JSON node before the typed decode \
(#1694 boundary class)"
)]
use serde_json::{Map, Value};
use crate::v1_2::model::{Container, RmAttribute, RmClass};
use crate::v1_2::validate::generated;
use crate::v1_2::validate::{
valid_iso8601_date, valid_iso8601_date_time, valid_iso8601_duration, valid_iso8601_time,
};
use openehr_base::validate::{InvariantViolation, Validate};
pub(super) fn try_validate(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) -> bool {
let Value::Object(obj) = value else {
return false;
};
let shallow = match ty {
"CLUSTER" | "POINT_EVENT" | "INTERVAL_EVENT" | "COMPOSITION" | "EVENT_CONTEXT"
| "ACTIVITY" | "OBSERVATION" | "INSTRUCTION" | "ACTION" | "EVALUATION" | "ADMIN_ENTRY"
| "SECTION" | "FOLDER" => true,
"CODE_PHRASE" | "DV_TEXT" | "DV_CODED_TEXT" | "DV_URI" | "DV_EHR_URI" | "DV_IDENTIFIER"
| "TERM_MAPPING" | "DV_QUANTITY" | "DV_COUNT" | "DV_ORDINAL" | "DV_SCALE"
| "DV_PROPORTION" | "DV_DURATION" | "DV_DATE" | "DV_TIME" | "DV_DATE_TIME"
| "DV_PARSABLE" | "ELEMENT" | "HISTORY" | "ARCHETYPED" | "PARTY_IDENTIFIED"
| "PARTY_RELATED" | "TERMINOLOGY_ID" | "ARCHETYPE_ID" => false,
_ => return false,
};
let Some(spec) = crate::v1_2::model::class(ty) else {
return false;
};
if !node_conforms(obj, spec, shallow) {
return false;
}
run_invariants(ty, obj, out)
}
fn fast_spec(name: &str) -> Option<&'static RmClass> {
let listed = matches!(
name,
"TERMINOLOGY_ID"
| "ARCHETYPE_ID"
| "TEMPLATE_ID"
| "HIER_OBJECT_ID"
| "OBJECT_VERSION_ID"
| "GENERIC_ID"
| "PARTY_REF"
| "CODE_PHRASE"
| "DV_TEXT"
| "DV_CODED_TEXT"
| "TERM_MAPPING"
| "DV_URI"
| "DV_EHR_URI"
| "DV_IDENTIFIER"
| "DV_BOOLEAN"
| "DV_PARSABLE"
| "DV_QUANTITY"
| "DV_COUNT"
| "DV_ORDINAL"
| "DV_SCALE"
| "DV_PROPORTION"
| "DV_DURATION"
| "DV_DATE"
| "DV_TIME"
| "DV_DATE_TIME"
| "ARCHETYPED"
| "LINK"
| "PARTY_IDENTIFIED"
| "PARTY_RELATED"
| "PARTY_SELF"
| "ELEMENT"
| "CLUSTER"
| "ITEM_TREE"
| "ITEM_LIST"
| "ITEM_SINGLE"
| "ITEM_TABLE"
| "HISTORY"
| "POINT_EVENT"
| "INTERVAL_EVENT"
| "COMPOSITION"
| "EVENT_CONTEXT"
| "SECTION"
| "OBSERVATION"
| "EVALUATION"
| "INSTRUCTION"
| "ACTION"
| "ADMIN_ENTRY"
| "ACTIVITY"
| "ISM_TRANSITION"
| "FOLDER"
);
if listed {
crate::v1_2::model::class(name)
} else {
None
}
}
fn generic_any_slot(class: &str, attr: &str) -> bool {
attr == "data" && matches!(class, "POINT_EVENT" | "INTERVAL_EVENT")
}
fn node_conforms(obj: &Map<String, Value>, class: &'static RmClass, shallow: bool) -> bool {
let mut mandatory_seen = 0usize;
for (key, v) in obj {
if key == "_type" {
continue; }
let Some(attr) = class.attributes.iter().find(|a| a.name == key) else {
continue;
};
match attribute_conforms(v, attr, class, shallow) {
AttrCheck::Refused => return false,
AttrCheck::Absent if attr.is_mandatory => return false,
AttrCheck::Conforms if attr.is_mandatory => mandatory_seen += 1,
AttrCheck::Absent | AttrCheck::Conforms => {}
}
}
mandatory_seen == class.attributes.iter().filter(|a| a.is_mandatory).count()
}
enum AttrCheck {
Absent,
Conforms,
Refused,
}
fn attribute_conforms(
v: &Value,
attr: &'static RmAttribute,
class: &'static RmClass,
shallow: bool,
) -> AttrCheck {
match attr.container {
Container::None => single_conforms(v, attr, class, shallow),
Container::List | Container::Set => list_conforms(v, attr, shallow),
Container::Hash => AttrCheck::Refused,
}
}
fn single_conforms(
v: &Value,
attr: &'static RmAttribute,
class: &'static RmClass,
shallow: bool,
) -> AttrCheck {
if v.is_null() {
return AttrCheck::Absent;
}
if generic_any_slot(class.name, attr.name) {
return AttrCheck::Conforms;
}
if value_conforms(v, attr.declared_type, shallow) {
AttrCheck::Conforms
} else {
AttrCheck::Refused
}
}
fn list_conforms(v: &Value, attr: &'static RmAttribute, shallow: bool) -> AttrCheck {
let Value::Array(items) = v else {
return AttrCheck::Refused;
};
let lower_bound_one = attr.cardinality.is_some_and(|c| c.lower >= 1) || attr.nonempty;
if lower_bound_one && items.is_empty() {
return AttrCheck::Refused;
}
let members_conform = if shallow {
shallow_members_conform(items, attr)
} else {
items
.iter()
.all(|item| value_conforms(item, attr.declared_type, false))
};
if members_conform {
AttrCheck::Conforms
} else {
AttrCheck::Refused
}
}
fn shallow_members_conform(items: &[Value], attr: &'static RmAttribute) -> bool {
let Some(first) = items.first() else {
return true;
};
items.iter().any(Value::is_object) && value_conforms(first, attr.declared_type, true)
}
fn value_conforms(v: &Value, declared: &str, shallow: bool) -> bool {
match declared {
"String" | "Hrid" => v.is_string(),
"Boolean" => v.is_boolean(),
"Real" | "Double" => v.is_number(),
"Integer" => v.as_i64().is_some_and(|n| i32::try_from(n).is_ok()),
"Integer64" => v.as_i64().is_some(),
"Character" => v.as_str().is_some_and(|s| s.chars().count() == 1),
_ => class_slot_conforms(v, declared, shallow),
}
}
fn class_slot_conforms(v: &Value, declared: &str, shallow: bool) -> bool {
let Value::Object(obj) = v else {
return false;
};
let Some(slot) = crate::v1_2::model::class(declared) else {
return false;
};
let tag = match obj.iter().next() {
Some((k, v)) if k == "_type" => Some(v),
_ => obj.get("_type"),
};
let target = match tag {
Some(Value::String(t)) => {
if !slot.descendants.contains(&t.as_str()) {
return false;
}
t.as_str()
}
Some(_) => return false,
None => {
if slot.is_abstract {
return false;
}
declared
}
};
let Some(spec) = fast_spec(target) else {
return false;
};
node_conforms(obj, spec, shallow)
}
fn field<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
obj.iter().find_map(|(k, v)| (k == key).then_some(v))
}
fn str_of<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
field(obj, key).and_then(Value::as_str)
}
fn f64_of(obj: &Map<String, Value>, key: &str) -> Option<f64> {
field(obj, key).and_then(Value::as_f64)
}
fn bool_of(obj: &Map<String, Value>, key: &str) -> Option<bool> {
field(obj, key).and_then(Value::as_bool)
}
fn present(obj: &Map<String, Value>, key: &str) -> bool {
field(obj, key).is_some_and(|v| !v.is_null())
}
#[expect(
clippy::too_many_lines,
reason = "a flat per-RM-class dispatch table; splitting it would scatter the mirror of the typed invariant table"
)]
fn run_invariants(ty: &str, obj: &Map<String, Value>, out: &mut Vec<InvariantViolation>) -> bool {
match ty {
"CODE_PHRASE" => {
let Some(code_string) = str_of(obj, "code_string") else {
return false;
};
generated::code_phrase_core(code_string, out);
}
"DV_TEXT" | "DV_CODED_TEXT" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
generated::dv_text_core(ty, value, str_of(obj, "formatting"), out);
}
"DV_URI" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
crate::v1_2::data_types::uri::dv_uri_impl::push_dv_uri_invariants(value, out);
}
"DV_EHR_URI" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
crate::v1_2::data_types::uri::dv_ehr_uri_impl::push_dv_ehr_uri_invariants(value, out);
}
"DV_IDENTIFIER" => {
let Some(id) = str_of(obj, "id") else {
return false;
};
generated::dv_identifier_core(id, out);
}
"TERM_MAPPING" => {
let Some(code) = str_of(obj, "match").and_then(|s| s.chars().next()) else {
return false;
};
generated::term_mapping_core(code, out);
}
"DV_PARSABLE" => {
let Some(formalism) = str_of(obj, "formalism") else {
return false;
};
generated::dv_parsable_core(formalism, out);
}
"DV_QUANTITY" | "DV_COUNT" | "DV_DURATION" => {
if ty == "DV_DURATION" {
let Some(value) = str_of(obj, "value") else {
return false;
};
generated::temporal_value_core(ty, valid_iso8601_duration(value), out);
}
generated::dv_amount_core(
ty,
f64_of(obj, "accuracy"),
bool_of(obj, "accuracy_is_percent"),
str_of(obj, "magnitude_status"),
out,
);
}
"DV_DATE" | "DV_TIME" | "DV_DATE_TIME" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
let valid = match ty {
"DV_DATE" => valid_iso8601_date(value),
"DV_TIME" => valid_iso8601_time(value),
_ => valid_iso8601_date_time(value),
};
generated::temporal_value_core(ty, valid, out);
generated::magnitude_status_core(ty, str_of(obj, "magnitude_status"), out);
}
"DV_ORDINAL" | "DV_SCALE" => {}
"DV_PROPORTION" => {
let (Some(numerator), Some(denominator)) =
(f64_of(obj, "numerator"), f64_of(obj, "denominator"))
else {
return false;
};
let Some(kind) = field(obj, "type")
.and_then(Value::as_i64)
.and_then(|n| i32::try_from(n).ok())
else {
return false;
};
let precision = field(obj, "precision")
.and_then(Value::as_i64)
.and_then(|n| i32::try_from(n).ok());
generated::dv_proportion_core(numerator, denominator, kind, precision, out);
generated::dv_amount_core(
ty,
f64_of(obj, "accuracy"),
bool_of(obj, "accuracy_is_percent"),
str_of(obj, "magnitude_status"),
out,
);
}
"ELEMENT" => {
let Some(node_id) = str_of(obj, "archetype_node_id") else {
return false;
};
crate::v1_2::data_structures::representation::element_impl::push_element_invariants(
present(obj, "value"),
present(obj, "null_flavour"),
present(obj, "null_reason"),
node_id,
out,
);
}
"CLUSTER" | "SECTION" | "FOLDER" | "POINT_EVENT" | "INTERVAL_EVENT" => {
let Some(node_id) = str_of(obj, "archetype_node_id") else {
return false;
};
generated::archetype_node_id_core(ty, node_id, out);
}
"HISTORY" => {
if present(obj, "period") {
return false;
}
let Some(node_id) = str_of(obj, "archetype_node_id") else {
return false;
};
let events_empty = field(obj, "events")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty);
generated::history_basic_core(events_empty, present(obj, "summary"), node_id, out);
}
"COMPOSITION" => {
let Some(node_id) = str_of(obj, "archetype_node_id") else {
return false;
};
generated::composition_core(present(obj, "archetype_details"), node_id, out);
}
"OBSERVATION" | "EVALUATION" | "INSTRUCTION" | "ACTION" | "ADMIN_ENTRY" => {
let Some(node_id) = str_of(obj, "archetype_node_id") else {
return false;
};
generated::entry_root_core(ty, present(obj, "archetype_details"), node_id, out);
}
"ACTIVITY" => {
let (Some(action_archetype_id), Some(node_id)) = (
str_of(obj, "action_archetype_id"),
str_of(obj, "archetype_node_id"),
) else {
return false;
};
generated::activity_core(action_archetype_id, node_id, out);
}
"EVENT_CONTEXT" => {
generated::event_context_core(str_of(obj, "location"), out);
}
"ARCHETYPED" => {
let Some(rm_version) = str_of(obj, "rm_version") else {
return false;
};
generated::archetyped_core(rm_version, out);
}
"PARTY_IDENTIFIED" | "PARTY_RELATED" => {
let has_identifiers = field(obj, "identifiers")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty());
generated::party_identified_core(
ty,
str_of(obj, "name"),
has_identifiers,
present(obj, "external_ref"),
out,
);
}
"TERMINOLOGY_ID" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
openehr_base::v1_3::prelude::TerminologyId {
value: value.to_owned(),
}
.validate_invariants(out);
}
"ARCHETYPE_ID" => {
let Some(value) = str_of(obj, "value") else {
return false;
};
openehr_base::v1_3::prelude::ArchetypeId {
value: value.to_owned(),
}
.validate_invariants(out);
}
_ => return false,
}
true
}