Skip to main content

polyspec_crudui_validator/validate/
mod.rs

1//! CRUDUI validation entry point (SPEC §2 pipeline).
2//!
3//! Wires the three CRUDUI passes in order (G5 compose first → §3 traversal → §2 G1
4//! value evaluation):
5//!
6//! 1. compose — `compose_spec` / `compose_properties` expand `$ref`/`$patch` into
7//!    a single spec. An unresolved composition returns `Err(ComposeLoadError)`
8//!    HERE (a LOAD failure, NOT `valid:false`) — an unresolved `$ref` never
9//!    yields `valid:true`.
10//! 2. validate — `Validator` traverses the composed spec and runs the `validate`
11//!    slot (conditional rule values evaluated by the CRUDUI expression engine, then
12//!    handed to the rule registry).
13//!
14//! The compose pass reuses the existing `crate::compose` module and the
15//! expression engine reuses `crate::expr`. Nothing re-implements them.
16
17mod canonical;
18mod choice_data;
19mod condition_tree;
20mod data_limits;
21pub mod errors;
22pub mod group_source;
23mod keyed_maps;
24mod length;
25mod membership;
26pub mod numeric;
27mod parameters;
28mod pattern;
29mod references;
30pub mod rules;
31mod unicode;
32pub mod validator;
33pub mod visibility;
34mod whitespace;
35
36pub use errors::{FormInputError, ValidateError};
37pub use validator::{ValidationError, ValidationResult, Validator};
38
39use serde_json::{Map, Value};
40
41use crate::compose::{
42    compose_properties, compose_spec, member_ordered, ComposeOptions, FileLoader, MemoryLoader,
43};
44use crate::forbidden_scan::scan_forbidden_keys;
45use data_limits::{check_data_limits, data_limits};
46
47/// Options for a CRUDUI validation run.
48#[derive(Default)]
49pub struct ValidateOptions<'a> {
50    /// Virtual file set for `$ref` resolution (default empty — no `$ref`).
51    pub files: Option<Map<String, Value>>,
52    /// A custom loader (overrides `files`).
53    pub loader: Option<&'a dyn FileLoader>,
54    /// Basepath for relative `$ref` resolution.
55    pub basepath: Option<String>,
56}
57
58/// Validate `data` against a CRUDUI spec. The spec may carry `$ref`/`$patch`; they are
59/// expanded first via the compose pass. An unresolved composition or a rule
60/// parameter outside the validation-rule definitions returns
61/// `Err(ValidateError::Load)` and data with the wrong shape returns
62/// `Err(ValidateError::Input)`; neither produces a validation result.
63///
64/// Two entry shapes (JS `validate`):
65///   (a) a full root group spec `{ type:'group', properties:{…} }` — compose the
66///       whole spec, then read its composed `properties`.
67///   (b) a properties-layer composition entry `{ $ref, $patch }` with no own
68///       `properties` — compose it AS a properties map directly.
69pub fn validate(
70    spec: &Value,
71    data: &Value,
72    options: &ValidateOptions,
73) -> Result<ValidationResult, ValidateError> {
74    // Root data is a request precondition, checked before composition.
75    if !data.is_object() {
76        return Err(FormInputError::new("Form data must be an object").into());
77    }
78    let owned_loader;
79    let loader: &dyn FileLoader = match options.loader {
80        Some(l) => l,
81        None => {
82            owned_loader = MemoryLoader::new(options.files.clone().unwrap_or_default());
83            &owned_loader
84        }
85    };
86    let opts = match &options.basepath {
87        Some(bp) => ComposeOptions::with_basepath(bp.clone()),
88        None => ComposeOptions::default(),
89    };
90
91    // The specification is read in member order; the data keeps its own order.
92    let spec = &member_ordered(spec);
93    let spec_map = spec.as_object().cloned().unwrap_or_default();
94
95    let has_own_properties = matches!(spec.get("properties"), Some(Value::Object(_)));
96    let is_composition_entry = spec_map.contains_key("$ref") || spec_map.contains_key("$patch");
97
98    let properties: Map<String, Value> = if is_composition_entry && !has_own_properties {
99        compose_properties(spec_map, loader, &opts)?
100    } else {
101        let composed = compose_spec(spec_map, loader, &opts)?;
102        composed
103            .get("properties")
104            .and_then(Value::as_object)
105            .cloned()
106            .unwrap_or_default()
107    };
108
109    // Load-path forbidden-scan (SPEC §6): walk the composed single spec to
110    // arbitrary depth and reject any forbidden meta key BEFORE validation entry.
111    // A hit returns Err(ComposeLoadError) (a LOAD failure), never `valid:false`.
112    // This closes the deep-nesting leak the typed model alone could not (R1).
113    let props_value = Value::Object(properties.clone());
114    scan_forbidden_keys(&props_value, &["properties".to_string()])?;
115    // The form root declarations are scanned like the fields they sit beside.
116    for key in ["buttons", "action"] {
117        if let Some(declared) = spec.get(key) {
118            scan_forbidden_keys(declared, &[key.to_string()])?;
119        }
120    }
121
122    // The data limits are checked after the forbidden-key scan and before the rule parameters.
123    let limits = data_limits(spec)?;
124    // Rule parameters are checked after composition and the forbidden-key scan.
125    let validator = Validator::new(properties)?;
126    // The data is measured before any rule.
127    check_data_limits(data, &limits)?;
128    validator.validate(data)
129}