Skip to main content

yaml_schema/validation/
objects.rs

1// A module to contain object type validation logic
2use std::borrow::Cow;
3use std::collections::HashSet;
4
5use hashlink::LinkedHashMap;
6use log::debug;
7use saphyr::Scalar;
8use saphyr::YamlData;
9
10use crate::Result;
11use crate::Validator;
12use crate::YamlSchema;
13use crate::schemas::BooleanOrSchema;
14use crate::schemas::ObjectSchema;
15use crate::utils::{format_marker, format_yaml_data, scalar_to_string};
16use crate::validation::Context;
17
18impl Validator for ObjectSchema {
19    /// Validate the object according to the schema rules
20    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
21        let data = &value.data;
22        debug!("Validating object: {}", format_yaml_data(data));
23        if let saphyr::YamlData::Mapping(mapping) = data {
24            self.validate_object_mapping(context, value, mapping)
25        } else {
26            let error_message = format!(
27                "[ObjectSchema] {} Expected an object, but got: {data:?}",
28                format_marker(&value.span.start)
29            );
30            debug!("{error_message}");
31            context.add_error(value, error_message);
32            Ok(())
33        }
34    }
35}
36
37pub fn try_validate_value_against_properties(
38    context: &Context,
39    key: &String,
40    value: &saphyr::MarkedYaml,
41    properties: &LinkedHashMap<String, YamlSchema>,
42) -> Result<bool> {
43    let sub_context = context.append_path(key);
44    if let Some(schema) = properties.get(key) {
45        debug!("Validating property '{key}' with schema: {schema}");
46        let err_before = context.errors.borrow().len();
47        let result = schema.validate(&sub_context, value);
48        return match result {
49            Ok(()) => {
50                if context.errors.borrow().len() == err_before {
51                    context.record_evaluated_property(key);
52                }
53                Ok(true)
54            }
55            Err(e) => Err(e),
56        };
57    }
58    Ok(false)
59}
60
61/// Try and validate the value against an object type's additional_properties
62///
63/// Returns true if the validation passed, or false if it failed (signals fail-fast)
64pub fn try_validate_value_against_additional_properties(
65    context: &Context,
66    key: &String,
67    value: &saphyr::MarkedYaml,
68    additional_properties: &BooleanOrSchema,
69) -> Result<bool> {
70    let sub_context = context.append_path(key);
71
72    match additional_properties {
73        // if additional_properties: true, then any additional properties are allowed
74        BooleanOrSchema::Boolean(true) => { /* noop */ }
75        // if additional_properties: false, then no additional properties are allowed
76        BooleanOrSchema::Boolean(false) => {
77            context.add_error(
78                value,
79                format!("Additional property '{key}' is not allowed!"),
80            );
81            // returning `false` signals fail fast
82            return Ok(false);
83        }
84        // if additional_properties: a schema, then validate against it
85        BooleanOrSchema::Schema(schema) => {
86            schema.validate(&sub_context, value)?;
87        }
88    }
89    Ok(true)
90}
91
92impl ObjectSchema {
93    fn validate_object_mapping<'r>(
94        &self,
95        context: &Context<'r>,
96        object: &saphyr::MarkedYaml,
97        mapping: &saphyr::AnnotatedMapping<'r, saphyr::MarkedYaml<'r>>,
98    ) -> Result<()> {
99        for (k, value) in mapping {
100            let key_string = match &k.data {
101                saphyr::YamlData::Value(scalar) => scalar_to_string(scalar),
102                v => {
103                    return Err(expected_scalar!(
104                        "[{}] Expected a scalar key, got: {:?}",
105                        format_marker(&k.span.start),
106                        v
107                    ));
108                }
109            };
110            let span = &k.span;
111            debug!("validate_object_mapping: key: \"{key_string}\"");
112            debug!(
113                "validate_object_mapping: span.start: {:?}",
114                format_marker(&span.start)
115            );
116            debug!(
117                "validate_object_mapping: span.end: {:?}",
118                format_marker(&span.end)
119            );
120
121            // Per JSON Schema spec (section 6), `$schema` is a meta-property
122            // used by tooling to identify the schema. Skip it during validation.
123            if key_string == "$schema" {
124                continue;
125            }
126
127            // `properties` and `patternProperties` both apply when they match (JSON Schema 2020-12).
128            let covered_by_properties = if let Some(properties) = &self.properties {
129                try_validate_value_against_properties(context, &key_string, value, properties)?
130            } else {
131                false
132            };
133
134            let mut matched_pattern_property = false;
135            if let Some(pattern_properties) = &self.pattern_properties {
136                let pattern_context = context.append_path(&key_string);
137                let err_before_patterns = context.errors.borrow().len();
138                for pp in pattern_properties {
139                    log::debug!("pattern: {}", pp.regex.as_str());
140                    if pp.regex.is_match(key_string.as_ref()) {
141                        matched_pattern_property = true;
142                        pp.schema.validate(&pattern_context, value)?;
143                    }
144                }
145                if matched_pattern_property && context.errors.borrow().len() == err_before_patterns
146                {
147                    context.record_evaluated_property(&key_string);
148                }
149            }
150
151            // additionalProperties applies only when the name is not in `properties` and matches
152            // no `patternProperties` regex (JSON Schema 2020-12).
153            if !covered_by_properties
154                && !matched_pattern_property
155                && let Some(additional_properties) = &self.additional_properties
156            {
157                let err_before_add = context.errors.borrow().len();
158                let passed = try_validate_value_against_additional_properties(
159                    context,
160                    &key_string,
161                    value,
162                    additional_properties,
163                )?;
164                if context.errors.borrow().len() == err_before_add {
165                    context.record_evaluated_property(&key_string);
166                }
167                if !passed {
168                    fail_fast!(context)
169                }
170            }
171            // propertyNames: validate each mapping key against the subschema.
172            if let Some(property_names) = &self.property_names {
173                let names_context = context.append_path(&key_string);
174                let key_to_validate = if property_names_validates_string_projection(property_names)
175                {
176                    string_projection_of_key(k, &key_string)
177                } else {
178                    k.clone()
179                };
180                property_names.validate(&names_context, &key_to_validate)?;
181            }
182        }
183
184        // Validate required properties
185        if let Some(required) = &self.required {
186            for required_property in required {
187                if !mapping
188                    .keys()
189                    .filter_map(|k| k.data.as_str())
190                    .any(|s| s == required_property)
191                {
192                    context.add_error(
193                        object,
194                        format!("Required property '{required_property}' is missing!"),
195                    );
196                    fail_fast!(context)
197                }
198            }
199        }
200
201        // Validate minProperties
202        if let Some(min_properties) = &self.min_properties
203            && mapping.len() < *min_properties
204        {
205            context.add_error(
206                object,
207                format!("Object has too few properties! Minimum is {min_properties}!"),
208            );
209            fail_fast!(context)
210        }
211        // Validate maxProperties
212        if let Some(max_properties) = &self.max_properties
213            && mapping.len() > *max_properties
214        {
215            context.add_error(
216                object,
217                format!("Object has too many properties! Maximum is {max_properties}!"),
218            );
219            fail_fast!(context)
220        }
221
222        // dependentRequired / dependentSchemas (JSON Schema 2020-12): after per-property and required/min/max.
223        if self.dependent_required.is_some() || self.dependent_schemas.is_some() {
224            let keys = Self::instance_property_keys(mapping)?;
225            if let Some(dr) = &self.dependent_required {
226                for (trigger, deps) in dr {
227                    if keys.contains(trigger) {
228                        for dep in deps {
229                            if !keys.contains(dep) {
230                                context.add_error(
231                                    object,
232                                    format!(
233                                        "{} When property '{}' is present, property '{}' is required by dependentRequired",
234                                        format_marker(&object.span.start),
235                                        trigger,
236                                        dep
237                                    ),
238                                );
239                                fail_fast!(context)
240                            }
241                        }
242                    }
243                }
244            }
245            if let Some(ds) = &self.dependent_schemas {
246                for (trigger, subschema) in ds {
247                    if keys.contains(trigger) {
248                        subschema.validate(context, object)?;
249                    }
250                }
251            }
252        }
253
254        Ok(())
255    }
256
257    /// Property names present on the instance mapping (scalar keys only, same rules as the main validation loop).
258    fn instance_property_keys<'r>(
259        mapping: &saphyr::AnnotatedMapping<'r, saphyr::MarkedYaml<'r>>,
260    ) -> Result<HashSet<String>> {
261        let mut keys = HashSet::new();
262        for (k, _) in mapping {
263            let key_string = match &k.data {
264                saphyr::YamlData::Value(scalar) => scalar_to_string(scalar),
265                v => {
266                    return Err(expected_scalar!(
267                        "[{}] Expected a scalar key, got: {:?}",
268                        format_marker(&k.span.start),
269                        v
270                    ));
271                }
272            };
273            keys.insert(key_string);
274        }
275        Ok(keys)
276    }
277}
278
279/// Whether `propertyNames` validates the canonical string form of each key.
280fn property_names_validates_string_projection(schema: &YamlSchema) -> bool {
281    if let YamlSchema::Subschema(subschema) = schema {
282        // For composition (`oneOf` / `anyOf` / `allOf`), validating the original YAML key node is
283        // necessary so non-string key types (e.g. integer/boolean) can match their branches.
284        if subschema.one_of.is_some() || subschema.any_of.is_some() || subschema.all_of.is_some() {
285            return false;
286        }
287
288        subschema.r#type.is_none_or_string()
289    } else {
290        true
291    }
292}
293
294/// Build a key node whose value is the string projection of `key`, preserving the key span.
295fn string_projection_of_key<'r>(
296    key: &saphyr::MarkedYaml<'r>,
297    key_string: &str,
298) -> saphyr::MarkedYaml<'r> {
299    let mut projected = key.clone();
300    projected.data = YamlData::Value(Scalar::String(Cow::Owned(key_string.to_string())));
301    projected
302}
303
304#[cfg(test)]
305mod tests {
306    use crate::RootSchema;
307    use crate::YamlSchema;
308    use crate::engine;
309    use crate::loader;
310    use crate::schemas::NumberSchema;
311    use crate::schemas::StringSchema;
312    use hashlink::LinkedHashMap;
313
314    use super::*;
315
316    #[test]
317    fn test_should_validate_properties() {
318        let mut properties = LinkedHashMap::new();
319        properties.insert(
320            "foo".to_string(),
321            YamlSchema::typed_string(StringSchema::default()),
322        );
323        properties.insert(
324            "bar".to_string(),
325            YamlSchema::typed_number(NumberSchema::default()),
326        );
327        let object_schema = ObjectSchema {
328            properties: Some(properties),
329            ..Default::default()
330        };
331        let root_schema = RootSchema::new(YamlSchema::typed_object(object_schema));
332        let value = r#"
333            foo: "I'm a string"
334            bar: 42
335        "#;
336        let result = engine::Engine::evaluate(&root_schema, value, true);
337        assert!(result.is_ok());
338
339        let value2 = r#"
340            foo: 42
341            baz: "I'm a string"
342        "#;
343        let context = engine::Engine::evaluate(&root_schema, value2, true).unwrap();
344        assert!(context.has_errors());
345        let errors = context.errors.borrow();
346        let first_error = errors.first().unwrap();
347        assert_eq!(first_error.path, "foo");
348        assert_eq!(first_error.error, "Expected a string, but got: 42 (int)");
349    }
350
351    #[test]
352    fn dependent_required_validation() {
353        let yaml = r#"
354        type: object
355        dependentRequired:
356          credit_card:
357            - billing_address
358        properties:
359          credit_card:
360            type: string
361          billing_address:
362            type: string
363        "#;
364        let root_schema = loader::load_from_str(yaml).unwrap();
365        let ok = engine::Engine::evaluate(
366            &root_schema,
367            "credit_card: \"4111\"\nbilling_address: \"1 Main\"",
368            false,
369        )
370        .unwrap();
371        assert!(!ok.has_errors());
372
373        let bad = engine::Engine::evaluate(&root_schema, "credit_card: \"4111\"", false).unwrap();
374        assert!(bad.has_errors());
375    }
376
377    #[test]
378    fn dependent_schemas_validation() {
379        let yaml = r#"
380        type: object
381        dependentSchemas:
382          credit_card:
383            type: object
384            required:
385              - billing_address
386        properties:
387          credit_card:
388            type: string
389          billing_address:
390            type: string
391        "#;
392        let root_schema = loader::load_from_str(yaml).unwrap();
393        let ok = engine::Engine::evaluate(
394            &root_schema,
395            "credit_card: \"4111\"\nbilling_address: \"1 Main\"",
396            false,
397        )
398        .unwrap();
399        assert!(!ok.has_errors());
400
401        let bad = engine::Engine::evaluate(&root_schema, "credit_card: \"4111\"", false).unwrap();
402        assert!(bad.has_errors());
403    }
404}