polyspec_crudui_validator/detail/mod.rs
1//! CRUDUI detail-spec structure validation.
2//!
3//! A detail contains no record data and no search, so this module performs only
4//! composition and forbidden-key scanning, at the same level as list validation.
5//!
6//! Pass 1 — compose: the detail root composes exactly as the list root does
7//! (`compose_spec` expands root `$ref`/`$patch`); an object `fields` map then
8//! expands with `compose_properties`, the same engine the list uses for
9//! `columns`. An unresolved composition is a LOAD failure (`ComposeLoadError`).
10//!
11//! Pass 2 — forbidden-scan (§6): `scan_forbidden_keys` walks the whole composed
12//! detail tree to arbitrary depth and rejects any forbidden meta key as a LOAD
13//! failure, with the trace seeded at the detail root.
14//!
15//! The meta-schema separately checks closed objects and shapes. The shared
16//! fixture `tests/fixtures/detail-validity/cases.json` declares both results.
17
18use serde_json::{Map, Value};
19
20use crate::compose::{ComposeLoadError, ComposeOptions, FileLoader};
21use crate::structure::{compose_map_slot, compose_root, validate_structure};
22
23/// Options for a detail-spec validation run (no data, so no `data` field).
24#[derive(Default)]
25pub struct ValidateDetailOptions<'a> {
26 /// Virtual file set for `$ref` resolution (default empty — no `$ref`).
27 pub files: Option<Map<String, Value>>,
28 /// A custom loader (overrides `files`).
29 pub loader: Option<&'a dyn FileLoader>,
30 /// Basepath for relative `$ref` resolution.
31 pub basepath: Option<String>,
32}
33
34/// Validate a detail-spec's STRUCTURE: compose then forbidden-scan (§6).
35///
36/// Returns `Ok(())` when the composed detail tree carries no forbidden meta key.
37/// Returns `Err(ComposeLoadError)` for an unresolved composition (`$ref`/`$patch`)
38/// or a forbidden meta key at any depth (`FORBIDDEN_META_KEY`). Both are LOAD
39/// failures, never a `valid:false` result.
40pub fn validate_detail(
41 spec: &Value,
42 options: &ValidateDetailOptions,
43) -> Result<(), ComposeLoadError> {
44 validate_structure(
45 spec,
46 options.files.as_ref(),
47 options.loader,
48 options.basepath.as_deref(),
49 compose_detail,
50 )
51}
52
53/// Compose a detail-spec: expand the root `$ref`/`$patch`, then the `fields` map.
54/// A non-object spec composes to itself.
55fn compose_detail(
56 spec: &Value,
57 loader: &dyn FileLoader,
58 opts: &ComposeOptions,
59) -> Result<Value, ComposeLoadError> {
60 let mut composed = match compose_root(spec, loader, opts)? {
61 Some(m) => m,
62 None => return Ok(spec.clone()),
63 };
64 compose_map_slot(&mut composed, "fields", loader, opts)?;
65 Ok(Value::Object(composed))
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71 use crate::compose::ComposeErrorCode;
72 use serde_json::json;
73
74 #[test]
75 fn minimal_fields_passes() {
76 let v = json!({ "fields": { "name": { "field": "name" } } });
77 assert!(validate_detail(&v, &ValidateDetailOptions::default()).is_ok());
78 }
79
80 #[test]
81 fn forbidden_field_key_is_load_error() {
82 let v = json!({ "fields": { "name": { "field": "name", "show_if": ".admin" } } });
83 let err = validate_detail(&v, &ValidateDetailOptions::default()).unwrap_err();
84 assert_eq!(err.code, ComposeErrorCode::ForbiddenMetaKey);
85 assert_eq!(err.trace.join("."), "fields.name.show_if");
86 }
87}