Skip to main content

ucp_schema/
validator.rs

1//! Payload validation against resolved schemas.
2
3use serde_json::{Map, Value};
4
5use crate::compose::is_container_schema;
6use crate::error::{ResolveError, SchemaError, ValidateError};
7use crate::resolver::resolve;
8use crate::types::ResolveOptions;
9
10/// Validate a payload against a UCP schema.
11///
12/// Resolves the schema for the given direction and operation, selects the
13/// operation shape for container-shaped capabilities, then validates the
14/// payload against the resulting schema.
15///
16/// # Errors
17///
18/// Returns `ValidateError::Resolve` if schema resolution or operation-shape
19/// selection fails, or `ValidateError::Invalid` if the payload doesn't match.
20pub fn validate(
21    schema: &Value,
22    payload: &Value,
23    options: &ResolveOptions,
24) -> Result<(), ValidateError> {
25    let resolved = resolve(schema, options)?;
26
27    // The message body to validate depends on the capability's shape:
28    // single-object capabilities validate at the root; container capabilities
29    // validate at the selected operation shape.
30    let target = select_operation_schema(&resolved, options)?;
31
32    validate_against_schema(&target, payload)
33}
34
35/// Resolve a (possibly container-shaped) schema to its validation target.
36///
37/// Selection has two modes:
38///
39/// - **Explicit** (`options.def_name`): root at the named `$defs` entry,
40///   regardless of schema shape. Names non-derivable shapes — transport message
41///   types (`error_response`), host views (`business_schema`) — and sub-types of
42///   single-object schemas (`cart` → `checkout`), where the root has a body but
43///   a fragment is being validated. Absent name → `DefNotFound`.
44/// - **Derived** (no `def_name`): single-object capabilities validate at the
45///   root unchanged; for a container capability (see
46///   [`crate::is_container_schema`]) the target is the message body for this
47///   `(op, direction)`, held at `$defs/{op}_{direction}`. A container root has
48///   no body of its own, so an absent shape → `OperationShapeNotFound` rather
49///   than a fall-through to an unconstrained root.
50///
51/// Either way the chosen `$def` is rooted via a `$ref` that keeps the sibling
52/// `$defs` and `$schema` in scope, so internal refs and the dialect resolve.
53pub fn select_operation_schema(
54    schema: &Value,
55    options: &ResolveOptions,
56) -> Result<Value, ResolveError> {
57    if let Some(def) = &options.def_name {
58        return select_def(schema, def, SelectMode::Explicit);
59    }
60    if !is_container_schema(schema) {
61        return Ok(schema.clone());
62    }
63    let key = format!("{}_{}", options.operation, options.direction.dir_str());
64    select_def(schema, &key, SelectMode::Derived)
65}
66
67/// Whether the selected `$def` name was authored (`--def`) or computed from
68/// `(op, direction)`. Only affects which "available" hint and error variant a
69/// miss produces.
70enum SelectMode {
71    Explicit,
72    Derived,
73}
74
75/// Root validation at `$defs/{name}` via a `$ref` wrapper that retains the
76/// sibling `$defs` and `$schema`.
77fn select_def(schema: &Value, name: &str, mode: SelectMode) -> Result<Value, ResolveError> {
78    let defs = schema.get("$defs").and_then(|d| d.as_object());
79    let present = defs.map(|d| d.contains_key(name)).unwrap_or(false);
80    if !present {
81        let available = defs
82            .map(|d| match mode {
83                // Derived selection only ever targets operation shapes, so the
84                // hint lists those; explicit selection can name any $def.
85                SelectMode::Derived => d
86                    .keys()
87                    .filter(|k| k.ends_with("_request") || k.ends_with("_response"))
88                    .cloned()
89                    .collect::<Vec<_>>(),
90                SelectMode::Explicit => d.keys().cloned().collect::<Vec<_>>(),
91            })
92            .unwrap_or_default()
93            .join(", ");
94        return Err(match mode {
95            SelectMode::Derived => ResolveError::OperationShapeNotFound {
96                key: name.to_string(),
97                available,
98            },
99            SelectMode::Explicit => ResolveError::DefNotFound {
100                def: name.to_string(),
101                available,
102            },
103        });
104    }
105
106    let mut wrapper = Map::new();
107    if let Some(s) = schema.get("$schema") {
108        wrapper.insert("$schema".to_string(), s.clone());
109    }
110    wrapper.insert(
111        "$ref".to_string(),
112        Value::String(format!("#/$defs/{}", name)),
113    );
114    if let Some(defs) = schema.get("$defs") {
115        wrapper.insert("$defs".to_string(), defs.clone());
116    }
117    Ok(Value::Object(wrapper))
118}
119
120/// Validate a payload against an already-resolved schema.
121///
122/// Use this when you've already resolved the schema and want to validate
123/// multiple payloads against it.
124pub fn validate_against_schema(schema: &Value, payload: &Value) -> Result<(), ValidateError> {
125    let validator = jsonschema::validator_for(schema).map_err(|e| {
126        ValidateError::Resolve(ResolveError::InvalidSchema {
127            message: e.to_string(),
128        })
129    })?;
130
131    let errors: Vec<SchemaError> = validator
132        .iter_errors(payload)
133        .map(|e| SchemaError {
134            path: e.instance_path.to_string(),
135            message: e.to_string(),
136        })
137        .collect();
138
139    if errors.is_empty() {
140        Ok(())
141    } else {
142        Err(ValidateError::Invalid { errors })
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::types::Direction;
150    use serde_json::json;
151
152    #[test]
153    fn validate_valid_payload() {
154        let schema = json!({
155            "type": "object",
156            "properties": {
157                "name": { "type": "string" }
158            },
159            "required": ["name"]
160        });
161        let payload = json!({ "name": "test" });
162        let options = ResolveOptions::new(Direction::Request, "create");
163
164        let result = validate(&schema, &payload, &options);
165        assert!(result.is_ok());
166    }
167
168    #[test]
169    fn validate_missing_required_field() {
170        let schema = json!({
171            "type": "object",
172            "properties": {
173                "name": { "type": "string", "ucp_request": "required" }
174            }
175        });
176        let payload = json!({});
177        let options = ResolveOptions::new(Direction::Request, "create");
178
179        let result = validate(&schema, &payload, &options);
180        assert!(matches!(result, Err(ValidateError::Invalid { .. })));
181    }
182
183    #[test]
184    fn validate_wrong_type() {
185        let schema = json!({
186            "type": "object",
187            "properties": {
188                "name": { "type": "string" }
189            }
190        });
191        let payload = json!({ "name": 123 });
192        let options = ResolveOptions::new(Direction::Request, "create");
193
194        let result = validate(&schema, &payload, &options);
195        assert!(matches!(result, Err(ValidateError::Invalid { .. })));
196    }
197
198    #[test]
199    fn validate_omitted_field_rejected() {
200        // When additionalProperties is false and a field is omitted,
201        // sending that field should fail validation
202        let schema = json!({
203            "type": "object",
204            "additionalProperties": false,
205            "properties": {
206                "id": { "type": "string", "ucp_request": "omit" },
207                "name": { "type": "string" }
208            }
209        });
210        let payload = json!({ "name": "test", "id": "123" });
211        let options = ResolveOptions::new(Direction::Request, "create");
212
213        let result = validate(&schema, &payload, &options);
214        assert!(matches!(result, Err(ValidateError::Invalid { .. })));
215    }
216
217    #[test]
218    fn validate_collects_multiple_errors() {
219        let schema = json!({
220            "type": "object",
221            "properties": {
222                "name": { "type": "string", "ucp_request": "required" },
223                "age": { "type": "number", "ucp_request": "required" }
224            }
225        });
226        let payload = json!({});
227        let options = ResolveOptions::new(Direction::Request, "create");
228
229        let result = validate(&schema, &payload, &options);
230        match result {
231            Err(ValidateError::Invalid { errors }) => {
232                assert_eq!(errors.len(), 2);
233            }
234            _ => panic!("expected validation error with 2 errors"),
235        }
236    }
237
238    #[test]
239    fn validate_allof_strict_accepts_properties_from_all_branches() {
240        // allOf with strict mode should accept properties defined in ANY branch
241        // This tests that unevaluatedProperties correctly sees all branch properties
242        let schema = json!({
243            "allOf": [
244                {
245                    "type": "object",
246                    "properties": {
247                        "id": { "type": "string" }
248                    }
249                },
250                {
251                    "type": "object",
252                    "properties": {
253                        "name": { "type": "string" }
254                    }
255                }
256            ]
257        });
258        // Payload uses properties from BOTH branches
259        let payload = json!({ "id": "123", "name": "test" });
260        let options = ResolveOptions::new(Direction::Request, "create").strict(true);
261
262        let result = validate(&schema, &payload, &options);
263        assert!(
264            result.is_ok(),
265            "should accept properties from all allOf branches"
266        );
267    }
268
269    #[test]
270    fn validate_allof_strict_rejects_unknown_properties() {
271        // allOf with strict mode should reject properties not in ANY branch
272        let schema = json!({
273            "allOf": [
274                {
275                    "type": "object",
276                    "properties": {
277                        "id": { "type": "string" }
278                    }
279                },
280                {
281                    "type": "object",
282                    "properties": {
283                        "name": { "type": "string" }
284                    }
285                }
286            ]
287        });
288        // Payload has unknown property
289        let payload = json!({ "id": "123", "name": "test", "unknown": "bad" });
290        let options = ResolveOptions::new(Direction::Request, "create").strict(true);
291
292        let result = validate(&schema, &payload, &options);
293        assert!(
294            matches!(result, Err(ValidateError::Invalid { .. })),
295            "should reject unknown properties in strict mode"
296        );
297    }
298
299    #[test]
300    fn validate_allof_non_strict_allows_unknown_properties() {
301        // allOf without strict mode should allow unknown properties (extensibility)
302        let schema = json!({
303            "allOf": [
304                {
305                    "type": "object",
306                    "properties": {
307                        "id": { "type": "string" }
308                    }
309                },
310                {
311                    "type": "object",
312                    "properties": {
313                        "name": { "type": "string" }
314                    }
315                }
316            ]
317        });
318        // Payload has unknown property
319        let payload = json!({ "id": "123", "name": "test", "unknown": "allowed" });
320        let options = ResolveOptions::new(Direction::Request, "create").strict(false);
321
322        let result = validate(&schema, &payload, &options);
323        assert!(
324            result.is_ok(),
325            "should allow unknown properties in non-strict mode"
326        );
327    }
328}