openbim_ifcx/validate.rs
1//! Checking attribute values against a file's `schemas`.
2//!
3//! The rules follow upstream's `Validate` in
4//! `src/ifcx-core/schema/schema-validation.ts` of the `ifcx_alpha` draft, with
5//! two differences: every failure is collected into a [`ValidationReport`]
6//! instead of stopping at the first, and a few cases upstream leaves open are
7//! decided here.
8//!
9//! # Rules
10//!
11//! Every attribute id on a node must name a schema; ids starting with
12//! `__internal` are skipped, as upstream does. The value is then checked
13//! against the schema's [`IfcxValueDescription`]:
14//!
15//! | `dataType` | Accepted value |
16//! | --- | --- |
17//! | `Boolean` | JSON `true` or `false` |
18//! | `String`, `Reference` | any JSON string; a reference is not resolved |
19//! | `DateTime` | any JSON string; the ISO 8601 format is not checked, as upstream |
20//! | `Enum` | a string equal to one of `enumRestrictions.options` |
21//! | `Real` | any JSON number |
22//! | `Integer` | a JSON number without a fractional part (`3` and `3.0`, not `3.5`) |
23//! | `Object` | a JSON object with every non-`optional` key of `objectRestrictions.values`, each checked recursively; other keys are allowed |
24//! | `Array` | a JSON array whose length is within `arrayRestrictions.min` and `max`, each element checked against `arrayRestrictions.value` |
25//! | `Blob` | any value, unchecked |
26//! | anything else | nothing; reported as [`FailureKind::UnknownDataType`] |
27//!
28//! A value description with `inherits` is first checked against the value
29//! description of every inherited schema, then against its own `dataType`.
30//! A schema reached through several `inherits` paths (a diamond) is checked
31//! once per value, so its failures are reported once. Inheritance is walked
32//! without recursion, so chains of any length are checked.
33//! `optional` only lets an object key be absent; a JSON `null` is never
34//! accepted where a value is present. `quantityKind` is not checked.
35//!
36//! # Where this is stricter or more defined than upstream
37//!
38//! - `Integer` rejects numbers with a fractional part; upstream accepts any
39//! number for both `Integer` and `Real`.
40//! - `Array` checks `min` and `max`; upstream ignores them.
41//! - `Object` requires a JSON object; upstream's `typeof value === "object"`
42//! also lets arrays and `null` through.
43//! - `Blob` accepts any value. The draft does not say how a blob is encoded,
44//! and upstream rejects every blob as an unexpected data type.
45//! - An `Enum` without `enumRestrictions` and an inheritance cycle are
46//! reported; upstream crashes or recurses without end.
47//! - `quantityKind` is an annotation for numbers. Neither upstream nor this
48//! module checks it against the value or against the list of known kinds.
49//!
50//! # Flattened and raw nodes
51//!
52//! Upstream validates flattened nodes, after all opinions on a path have been
53//! merged. [`validate_flat`] checks the output of [`flatten`](crate::flatten)
54//! or [`flatten_owned`](crate::flatten_owned). [`IfcxFile::validate`] merges
55//! the attributes of nodes sharing a path, later opinions winning, as
56//! flattening does, before validating them; it does not copy any value.
57//! [`validate_attributes`] takes any `(path, attributes)` pairs, and
58//! [`validate_nodes`] raw nodes as they are.
59//!
60//! # Imports
61//!
62//! A file's own `schemas` often do not describe its attributes: most
63//! upstream examples take them from imported files.
64//! [`LayerStack::validate`](crate::layers::LayerStack::validate) checks a
65//! layer stack built with its imports: the schemas of every layer, merged as
66//! `federate` does, against the attributes of every layer, merged per path as
67//! flattening does.
68
69use std::collections::HashSet;
70use std::error::Error;
71use std::fmt;
72
73use indexmap::IndexMap;
74use serde_json::{Number, Value};
75
76use crate::compose::FlatNode;
77use crate::model::{DataType, IfcxFile, IfcxNode, IfcxSchema, IfcxValueDescription};
78
79/// Attribute ids with this prefix are not validated, as upstream.
80pub const INTERNAL_ATTRIBUTE_PREFIX: &str = "__internal";
81
82/// The JSON type of a value, for failure messages.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub enum JsonType {
85 Null,
86 Boolean,
87 Number,
88 String,
89 Array,
90 Object,
91}
92
93impl JsonType {
94 pub fn of(value: &Value) -> Self {
95 match value {
96 Value::Null => Self::Null,
97 Value::Bool(_) => Self::Boolean,
98 Value::Number(_) => Self::Number,
99 Value::String(_) => Self::String,
100 Value::Array(_) => Self::Array,
101 Value::Object(_) => Self::Object,
102 }
103 }
104
105 pub fn as_str(self) -> &'static str {
106 match self {
107 Self::Null => "null",
108 Self::Boolean => "boolean",
109 Self::Number => "number",
110 Self::String => "string",
111 Self::Array => "array",
112 Self::Object => "object",
113 }
114 }
115}
116
117impl fmt::Display for JsonType {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(self.as_str())
120 }
121}
122
123/// What is wrong with one attribute value.
124#[derive(Debug, Clone, PartialEq)]
125#[non_exhaustive]
126pub enum FailureKind {
127 /// The attribute id names no schema.
128 MissingSchema,
129 /// A value description inherits from a schema id that does not exist.
130 UnknownInheritedSchema { id: String },
131 /// Following `inherits` leads back to a schema already being checked.
132 InheritanceCycle { id: String },
133 /// The value has the wrong JSON type for the `dataType`.
134 TypeMismatch { expected: DataType, found: JsonType },
135 /// An `Integer` value has a fractional part.
136 NotAnInteger { value: Number },
137 /// An `Enum` value is not one of the options.
138 NotAnOption { value: String, options: Vec<String> },
139 /// An object lacks a key that is not `optional`.
140 MissingKey { key: String },
141 /// An array has fewer elements than `arrayRestrictions.min`.
142 TooFewElements { len: usize, min: Number },
143 /// An array has more elements than `arrayRestrictions.max`.
144 TooManyElements { len: usize, max: Number },
145 /// An `Enum` or `Array` description lacks the restrictions it needs, so
146 /// the value cannot be checked.
147 MissingRestrictions { data_type: DataType },
148 /// The `dataType` is not one this crate knows, so the value cannot be
149 /// checked.
150 UnknownDataType { name: String },
151}
152
153impl fmt::Display for FailureKind {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::MissingSchema => f.write_str("no schema with this id"),
157 Self::UnknownInheritedSchema { id } => {
158 write!(f, "inherits unknown schema \"{id}\"")
159 }
160 Self::InheritanceCycle { id } => {
161 write!(f, "schema \"{id}\" inherits from itself")
162 }
163 Self::TypeMismatch { expected, found } => {
164 write!(f, "expected {}, found {found}", expected.as_str())
165 }
166 Self::NotAnInteger { value } => write!(f, "expected Integer, found {value}"),
167 Self::NotAnOption { value, options } => {
168 write!(f, "\"{value}\" is not one of [{}]", options.join(", "))
169 }
170 Self::MissingKey { key } => write!(f, "missing key \"{key}\""),
171 Self::TooFewElements { len, min } => {
172 write!(f, "array has {len} elements, fewer than min {min}")
173 }
174 Self::TooManyElements { len, max } => {
175 write!(f, "array has {len} elements, more than max {max}")
176 }
177 Self::MissingRestrictions { data_type } => {
178 let field = match data_type {
179 DataType::Enum => "enumRestrictions",
180 DataType::Array => "arrayRestrictions",
181 _ => "restrictions",
182 };
183 write!(f, "{} schema has no {field}", data_type.as_str())
184 }
185 Self::UnknownDataType { name } => write!(f, "unknown dataType \"{name}\""),
186 }
187 }
188}
189
190/// One attribute value that does not match its schema.
191#[derive(Debug, Clone, PartialEq)]
192pub struct ValidationFailure {
193 /// Path of the node carrying the attribute.
194 pub node: String,
195 /// Attribute id, which is also the schema id.
196 pub attribute: String,
197 /// RFC 6901 JSON pointer into the attribute value; empty for the value
198 /// itself, `/points/0` for the first element of its `points` key.
199 pub pointer: String,
200 pub kind: FailureKind,
201}
202
203impl fmt::Display for ValidationFailure {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 write!(f, "[\"{}\"].attributes[\"{}\"]", self.node, self.attribute)?;
206 if !self.pointer.is_empty() {
207 write!(f, " at {}", self.pointer)?;
208 }
209 write!(f, ": {}", self.kind)
210 }
211}
212
213impl Error for ValidationFailure {}
214
215/// Every failure found in one validation run, in node and attribute order.
216#[derive(Debug, Clone, Default, PartialEq)]
217pub struct ValidationReport {
218 pub failures: Vec<ValidationFailure>,
219}
220
221impl ValidationReport {
222 pub fn is_valid(&self) -> bool {
223 self.failures.is_empty()
224 }
225
226 /// `Ok` when nothing failed, otherwise the report as the error.
227 pub fn into_result(self) -> Result<(), ValidationReport> {
228 if self.is_valid() {
229 Ok(())
230 } else {
231 Err(self)
232 }
233 }
234}
235
236impl fmt::Display for ValidationReport {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 match self.failures.len() {
239 0 => return f.write_str("all attributes match their schemas"),
240 1 => f.write_str("1 attribute value does not match its schema")?,
241 n => write!(f, "{n} attribute values do not match their schemas")?,
242 }
243 for failure in &self.failures {
244 write!(f, "\n {failure}")?;
245 }
246 Ok(())
247 }
248}
249
250impl Error for ValidationReport {}
251
252/// Checks the attributes of `nodes`, given as `(path, attributes)` pairs,
253/// against `schemas` and collects every failure.
254///
255/// The pairs may come from raw file nodes or from flattened ones; see the
256/// [module documentation](self) for the rules.
257///
258/// ```
259/// use indexmap::IndexMap;
260/// use openbim_ifcx::{validate_attributes, FailureKind, IfcxSchema};
261/// use serde_json::json;
262///
263/// let schemas: IndexMap<String, IfcxSchema> = serde_json::from_value(json!({
264/// "example::height": {"value": {"dataType": "Real"}}
265/// })).unwrap();
266/// let attributes: IndexMap<String, serde_json::Value> = serde_json::from_value(json!({
267/// "example::height": "tall",
268/// "example::colour": "red"
269/// })).unwrap();
270///
271/// let report = validate_attributes(&schemas, [("wall", &attributes)]);
272/// assert_eq!(report.failures.len(), 2);
273/// assert_eq!(report.failures[1].kind, FailureKind::MissingSchema);
274/// ```
275pub fn validate_attributes<'a, P, I>(
276 schemas: &IndexMap<String, IfcxSchema>,
277 nodes: I,
278) -> ValidationReport
279where
280 P: AsRef<str> + 'a,
281 I: IntoIterator<Item = (P, &'a IndexMap<String, Value>)>,
282{
283 check_nodes(
284 schemas,
285 nodes.into_iter().map(|(path, attributes)| {
286 (
287 path,
288 attributes.iter().map(|(id, value)| (id.as_str(), value)),
289 )
290 }),
291 )
292}
293
294/// Checks flattened nodes, as [`flatten`](crate::flatten) or
295/// [`flatten_owned`](crate::flatten_owned) return them, against `schemas`.
296/// This is what upstream validates: one merged node per path, in the
297/// flattened order.
298///
299/// ```
300/// use openbim_ifcx::{flatten_owned, validate_flat, IfcxFile};
301///
302/// let file = IfcxFile::from_json_str(r#"{
303/// "header": {"id": "demo", "ifcxVersion": "ifcx_alpha", "dataVersion": "1.0.0",
304/// "author": "someone", "timestamp": "2026-10-03"},
305/// "imports": [],
306/// "schemas": {"x::height": {"value": {"dataType": "Real"}}},
307/// "data": [{"path": "w", "attributes": {"x::height": "tall"}},
308/// {"path": "w", "attributes": {"x::height": 3.0}}]
309/// }"#)?;
310/// // Only the value that wins is checked.
311/// assert!(validate_flat(&file.schemas, &flatten_owned(file.data.clone())).is_valid());
312/// # Ok::<(), openbim_ifcx::ReadError>(())
313/// ```
314pub fn validate_flat(
315 schemas: &IndexMap<String, IfcxSchema>,
316 nodes: &IndexMap<String, FlatNode>,
317) -> ValidationReport {
318 check_nodes(
319 schemas,
320 nodes.iter().map(|(path, node)| {
321 (
322 path,
323 node.attributes
324 .iter()
325 .map(|(id, value)| (id.as_str(), &**value)),
326 )
327 }),
328 )
329}
330
331/// The shared loop: `(path, attributes)` pairs whose attributes are
332/// `(id, value)` pairs.
333fn check_nodes<'v, P, A, I>(schemas: &IndexMap<String, IfcxSchema>, nodes: I) -> ValidationReport
334where
335 P: AsRef<str>,
336 A: IntoIterator<Item = (&'v str, &'v Value)>,
337 I: IntoIterator<Item = (P, A)>,
338{
339 let mut report = ValidationReport::default();
340 for (path, attributes) in nodes {
341 let path = path.as_ref();
342 for (id, value) in attributes {
343 if id.starts_with(INTERNAL_ATTRIBUTE_PREFIX) {
344 continue;
345 }
346 let mut checker = Checker {
347 schemas,
348 node: path,
349 attribute: id,
350 pointer: String::new(),
351 inheriting: HashSet::new(),
352 failures: &mut report.failures,
353 };
354 match schemas.get_key_value(id) {
355 Some((key, schema)) => {
356 checker.inheriting.insert(key);
357 checker.check(&schema.value, value);
358 }
359 None => checker.fail(FailureKind::MissingSchema),
360 }
361 }
362 }
363 report
364}
365
366/// Checks the attributes of each node as it is, without merging nodes that
367/// share a path. Nodes without attributes are skipped.
368pub fn validate_nodes<'a, I>(schemas: &IndexMap<String, IfcxSchema>, nodes: I) -> ValidationReport
369where
370 I: IntoIterator<Item = &'a IfcxNode>,
371{
372 validate_attributes(
373 schemas,
374 nodes
375 .into_iter()
376 .filter_map(|node| Some((node.path.as_str(), node.attributes.as_ref()?))),
377 )
378}
379
380impl IfcxFile {
381 /// Checks every attribute in `data` against this file's `schemas`.
382 ///
383 /// Nodes sharing a path are merged first, later attribute values
384 /// replacing earlier ones, as upstream's flattening does; a failure is
385 /// reported once per path, in order of each path's first node. The
386 /// result equals [`validate_flat`] over [`flatten`](crate::flatten) of
387 /// `data`, but no value is copied.
388 ///
389 /// Imports are not resolved, so attributes whose schemas live only in an
390 /// imported file are reported as [`FailureKind::MissingSchema`]. To
391 /// validate a file with its imports, build its
392 /// [`LayerStack`](crate::layers::LayerStack) and call
393 /// [`LayerStack::validate`](crate::layers::LayerStack::validate).
394 pub fn validate(&self) -> Result<(), ValidationReport> {
395 check_merged(&self.schemas, &self.data).into_result()
396 }
397}
398
399/// Merges the attributes of `nodes` per path, later values replacing earlier
400/// ones in place, as flattening does, without copying any value.
401fn merge_attributes<'a>(
402 nodes: impl IntoIterator<Item = &'a IfcxNode>,
403) -> IndexMap<&'a str, IndexMap<&'a str, &'a Value>> {
404 let mut merged: IndexMap<&str, IndexMap<&str, &Value>> = IndexMap::new();
405 for node in nodes {
406 let entry = merged.entry(node.path.as_str()).or_default();
407 for (id, value) in node.attributes.iter().flatten() {
408 entry.insert(id.as_str(), value);
409 }
410 }
411 merged
412}
413
414fn check_merged<'a>(
415 schemas: &IndexMap<String, IfcxSchema>,
416 nodes: impl IntoIterator<Item = &'a IfcxNode>,
417) -> ValidationReport {
418 let merged = merge_attributes(nodes);
419 check_nodes(
420 schemas,
421 merged
422 .iter()
423 .map(|(path, attributes)| (*path, attributes.iter().map(|(id, value)| (*id, *value)))),
424 )
425}
426
427/// Validates `files` as one federated file: schemas merged in order (a
428/// repeated id keeps its first position and takes the last value), and the
429/// data of every file merged per path in order.
430pub(crate) fn validate_files<'a>(
431 files: impl IntoIterator<Item = &'a IfcxFile>,
432) -> ValidationReport {
433 let files: Vec<&IfcxFile> = files.into_iter().collect();
434 let mut schemas: IndexMap<String, IfcxSchema> = IndexMap::new();
435 for file in &files {
436 for (id, schema) in &file.schemas {
437 schemas.insert(id.clone(), schema.clone());
438 }
439 }
440 check_merged(&schemas, files.iter().flat_map(|file| &file.data))
441}
442
443struct Checker<'s, 'r> {
444 schemas: &'s IndexMap<String, IfcxSchema>,
445 node: &'r str,
446 attribute: &'r str,
447 pointer: String,
448 /// Schema ids whose value description is being checked through
449 /// `inherits`, to stop cycles.
450 inheriting: HashSet<&'s str>,
451 failures: &'r mut Vec<ValidationFailure>,
452}
453
454impl<'s> Checker<'s, '_> {
455 fn fail(&mut self, kind: FailureKind) {
456 self.failures.push(ValidationFailure {
457 node: self.node.to_owned(),
458 attribute: self.attribute.to_owned(),
459 pointer: self.pointer.clone(),
460 kind,
461 });
462 }
463
464 fn mismatch(&mut self, expected: &DataType, value: &Value) {
465 self.fail(FailureKind::TypeMismatch {
466 expected: expected.clone(),
467 found: JsonType::of(value),
468 });
469 }
470
471 /// Checks `value` at the current pointer, then restores the pointer.
472 fn check_at(&mut self, token: &str, desc: &'s IfcxValueDescription, value: &Value) {
473 let len = self.pointer.len();
474 self.pointer.push('/');
475 for c in token.chars() {
476 match c {
477 '~' => self.pointer.push_str("~0"),
478 '/' => self.pointer.push_str("~1"),
479 c => self.pointer.push(c),
480 }
481 }
482 self.check(desc, value);
483 self.pointer.truncate(len);
484 }
485
486 /// Checks `value` against every schema `desc` inherits, each before the
487 /// schemas inheriting it, then against `desc` itself.
488 ///
489 /// The inheritance graph is walked with an explicit stack, so a long
490 /// chain cannot overflow the call stack, and each inherited schema is
491 /// checked once per value, so diamond-shaped inheritance stays linear.
492 fn check(&mut self, desc: &'s IfcxValueDescription, value: &Value) {
493 // Schemas already checked against this value.
494 let mut done: HashSet<&'s str> = HashSet::new();
495 // (description, its schema id unless it is `desc`, next inherit).
496 let mut stack: Vec<(&'s IfcxValueDescription, Option<&'s str>, usize)> =
497 vec![(desc, None, 0)];
498 while let Some((current, _, next)) = stack.last_mut() {
499 if let Some(id) = current.inherits.as_deref().and_then(|ids| ids.get(*next)) {
500 *next += 1;
501 let Some((key, schema)) = self.schemas.get_key_value(id) else {
502 self.fail(FailureKind::UnknownInheritedSchema { id: id.clone() });
503 continue;
504 };
505 if self.inheriting.contains(key.as_str()) {
506 self.fail(FailureKind::InheritanceCycle { id: id.clone() });
507 } else if !done.contains(key.as_str()) {
508 self.inheriting.insert(key);
509 stack.push((&schema.value, Some(key), 0));
510 }
511 continue;
512 }
513 let (current, key, _) = stack.pop().expect("stack is not empty");
514 self.check_own(current, value);
515 if let Some(key) = key {
516 self.inheriting.remove(key);
517 done.insert(key);
518 }
519 }
520 }
521
522 /// Checks `value` against the `dataType` of `desc` alone.
523 fn check_own(&mut self, desc: &'s IfcxValueDescription, value: &Value) {
524 let data_type = &desc.data_type;
525 match data_type {
526 DataType::Boolean => {
527 if !value.is_boolean() {
528 self.mismatch(data_type, value);
529 }
530 }
531 DataType::String | DataType::DateTime | DataType::Reference => {
532 if !value.is_string() {
533 self.mismatch(data_type, value);
534 }
535 }
536 DataType::Real => {
537 if !value.is_number() {
538 self.mismatch(data_type, value);
539 }
540 }
541 DataType::Integer => match value {
542 Value::Number(n) => {
543 let integral = n.is_i64()
544 || n.is_u64()
545 || n.as_f64()
546 .is_some_and(|f| f.is_finite() && f.fract() == 0.0);
547 if !integral {
548 self.fail(FailureKind::NotAnInteger { value: n.clone() });
549 }
550 }
551 _ => self.mismatch(data_type, value),
552 },
553 DataType::Enum => {
554 let Value::String(s) = value else {
555 return self.mismatch(data_type, value);
556 };
557 let Some(restrictions) = &desc.enum_restrictions else {
558 return self.fail(FailureKind::MissingRestrictions {
559 data_type: data_type.clone(),
560 });
561 };
562 if !restrictions.options.contains(s) {
563 self.fail(FailureKind::NotAnOption {
564 value: s.clone(),
565 options: restrictions.options.clone(),
566 });
567 }
568 }
569 DataType::Object => {
570 let Value::Object(object) = value else {
571 return self.mismatch(data_type, value);
572 };
573 let Some(restrictions) = &desc.object_restrictions else {
574 return;
575 };
576 for (key, key_desc) in &restrictions.values {
577 match object.get(key) {
578 Some(v) => self.check_at(key, key_desc, v),
579 None if key_desc.optional == Some(true) => {}
580 None => self.fail(FailureKind::MissingKey { key: key.clone() }),
581 }
582 }
583 }
584 DataType::Array => {
585 let Value::Array(elements) = value else {
586 return self.mismatch(data_type, value);
587 };
588 let Some(restrictions) = &desc.array_restrictions else {
589 return self.fail(FailureKind::MissingRestrictions {
590 data_type: data_type.clone(),
591 });
592 };
593 let len = elements.len();
594 if let Some(min) = &restrictions.min {
595 if (len as f64) < min.as_f64().unwrap_or(f64::NEG_INFINITY) {
596 self.fail(FailureKind::TooFewElements {
597 len,
598 min: min.clone(),
599 });
600 }
601 }
602 if let Some(max) = &restrictions.max {
603 if (len as f64) > max.as_f64().unwrap_or(f64::INFINITY) {
604 self.fail(FailureKind::TooManyElements {
605 len,
606 max: max.clone(),
607 });
608 }
609 }
610 for (i, element) in elements.iter().enumerate() {
611 self.check_at(&i.to_string(), &restrictions.value, element);
612 }
613 }
614 DataType::Blob => {}
615 DataType::Other(name) => self.fail(FailureKind::UnknownDataType { name: name.clone() }),
616 }
617 }
618}