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}