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}