Skip to main content

yaml_schema/validation/
objects.rs

1// A module to contain object type validation logic
2use hashlink::LinkedHashMap;
3use log::{debug, error};
4
5use crate::Error;
6use crate::Result;
7use crate::Validator;
8use crate::YamlSchema;
9use crate::schemas::BooleanOrSchema;
10use crate::schemas::ObjectSchema;
11use crate::utils::{format_marker, format_yaml_data, scalar_to_string};
12use crate::validation::Context;
13
14impl Validator for ObjectSchema<'_> {
15    /// Validate the object according to the schema rules
16    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
17        let data = &value.data;
18        debug!("Validating object: {}", format_yaml_data(data));
19        if let saphyr::YamlData::Mapping(mapping) = data {
20            self.validate_object_mapping(context, value, mapping)
21        } else {
22            let error_message = format!(
23                "[ObjectSchema] {} Expected an object, but got: {data:?}",
24                format_marker(&value.span.start)
25            );
26            error!("{error_message}");
27            context.add_error(value, error_message);
28            Ok(())
29        }
30    }
31}
32
33pub fn try_validate_value_against_properties(
34    context: &Context,
35    key: &String,
36    value: &saphyr::MarkedYaml,
37    properties: &LinkedHashMap<String, YamlSchema<'_>>,
38) -> Result<bool> {
39    let sub_context = context.append_path(key);
40    if let Some(schema) = properties.get(key) {
41        debug!("Validating property '{key}' with schema: {schema}");
42        let result = schema.validate(&sub_context, value);
43        return match result {
44            Ok(_) => Ok(true),
45            Err(e) => Err(e),
46        };
47    }
48    Ok(false)
49}
50
51/// Try and validate the value against an object type's additional_properties
52///
53/// Returns true if the validation passed, or false if it failed (signals fail-fast)
54pub fn try_validate_value_against_additional_properties(
55    context: &Context,
56    key: &String,
57    value: &saphyr::MarkedYaml,
58    additional_properties: &BooleanOrSchema,
59) -> Result<bool> {
60    let sub_context = context.append_path(key);
61
62    match additional_properties {
63        // if additional_properties: true, then any additional properties are allowed
64        BooleanOrSchema::Boolean(true) => { /* noop */ }
65        // if additional_properties: false, then no additional properties are allowed
66        BooleanOrSchema::Boolean(false) => {
67            context.add_error(
68                value,
69                format!("Additional property '{key}' is not allowed!"),
70            );
71            // returning `false` signals fail fast
72            return Ok(false);
73        }
74        // if additional_properties: a schema, then validate against it
75        BooleanOrSchema::Schema(schema) => {
76            schema.validate(&sub_context, value)?;
77        }
78    }
79    Ok(true)
80}
81
82impl ObjectSchema<'_> {
83    fn validate_object_mapping<'r>(
84        &self,
85        context: &Context<'r>,
86        object: &saphyr::MarkedYaml,
87        mapping: &saphyr::AnnotatedMapping<'r, saphyr::MarkedYaml<'r>>,
88    ) -> Result<()> {
89        for (k, value) in mapping {
90            let key_string = match &k.data {
91                saphyr::YamlData::Value(scalar) => scalar_to_string(scalar),
92                v => {
93                    return Err(expected_scalar!(
94                        "[{}] Expected a scalar key, got: {:?}",
95                        format_marker(&k.span.start),
96                        v
97                    ));
98                }
99            };
100            let span = &k.span;
101            debug!("validate_object_mapping: key: \"{key_string}\"");
102            debug!(
103                "validate_object_mapping: span.start: {:?}",
104                format_marker(&span.start)
105            );
106            debug!(
107                "validate_object_mapping: span.end: {:?}",
108                format_marker(&span.end)
109            );
110
111            // Per JSON Schema spec (section 6), `$schema` is a meta-property
112            // used by tooling to identify the schema. Skip it during validation.
113            if key_string == "$schema" {
114                continue;
115            }
116
117            // First, we check the explicitly defined properties, and validate against it if found
118            if let Some(properties) = &self.properties
119                && try_validate_value_against_properties(context, &key_string, value, properties)?
120            {
121                continue;
122            }
123
124            let mut matched_pattern_property = false;
125            if let Some(pattern_properties) = &self.pattern_properties {
126                for pp in pattern_properties {
127                    log::debug!("pattern: {}", pp.regex.as_str());
128                    if pp.regex.is_match(key_string.as_ref()) {
129                        matched_pattern_property = true;
130                        pp.schema.validate(context, value)?;
131                    }
132                }
133            }
134
135            // additionalProperties only applies when a property does not match
136            // either explicit properties or patternProperties.
137            if !matched_pattern_property
138                && let Some(additional_properties) = &self.additional_properties
139            {
140                try_validate_value_against_additional_properties(
141                    context,
142                    &key_string,
143                    value,
144                    additional_properties,
145                )?;
146            }
147            // Finally, we check if it matches property_names
148            if let Some(property_names) = &self.property_names {
149                if let Some(re) = &property_names.pattern {
150                    debug!("Regex for property names: {}", re.as_str());
151                    if !re.is_match(key_string.as_ref()) {
152                        context.add_error(
153                            k,
154                            format!(
155                                "Property name '{}' does not match pattern '{}'",
156                                key_string,
157                                re.as_str()
158                            ),
159                        );
160                        fail_fast!(context)
161                    }
162                } else {
163                    return Err(Error::GenericError(
164                        "Expected a pattern for `property_names`".to_string(),
165                    ));
166                }
167            }
168        }
169
170        // Validate required properties
171        if let Some(required) = &self.required {
172            for required_property in required {
173                if !mapping
174                    .keys()
175                    .filter_map(|k| k.data.as_str())
176                    .any(|s| s == required_property)
177                {
178                    context.add_error(
179                        object,
180                        format!("Required property '{required_property}' is missing!"),
181                    );
182                    fail_fast!(context)
183                }
184            }
185        }
186
187        // Validate minProperties
188        if let Some(min_properties) = &self.min_properties
189            && mapping.len() < *min_properties
190        {
191            context.add_error(
192                object,
193                format!("Object has too few properties! Minimum is {min_properties}!"),
194            );
195            fail_fast!(context)
196        }
197        // Validate maxProperties
198        if let Some(max_properties) = &self.max_properties
199            && mapping.len() > *max_properties
200        {
201            context.add_error(
202                object,
203                format!("Object has too many properties! Maximum is {max_properties}!"),
204            );
205            fail_fast!(context)
206        }
207
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::RootSchema;
215    use crate::YamlSchema;
216    use crate::engine;
217    use crate::schemas::NumberSchema;
218    use crate::schemas::StringSchema;
219    use hashlink::LinkedHashMap;
220
221    use super::*;
222
223    #[test]
224    fn test_should_validate_properties() {
225        let mut properties = LinkedHashMap::new();
226        properties.insert(
227            "foo".to_string(),
228            YamlSchema::typed_string(StringSchema::default()),
229        );
230        properties.insert(
231            "bar".to_string(),
232            YamlSchema::typed_number(NumberSchema::default()),
233        );
234        let object_schema = ObjectSchema {
235            properties: Some(properties),
236            ..Default::default()
237        };
238        let root_schema = RootSchema::new(YamlSchema::typed_object(object_schema));
239        let value = r#"
240            foo: "I'm a string"
241            bar: 42
242        "#;
243        let result = engine::Engine::evaluate(&root_schema, value, true);
244        assert!(result.is_ok());
245
246        let value2 = r#"
247            foo: 42
248            baz: "I'm a string"
249        "#;
250        let context = engine::Engine::evaluate(&root_schema, value2, true).unwrap();
251        assert!(context.has_errors());
252        let errors = context.errors.borrow();
253        let first_error = errors.first().unwrap();
254        assert_eq!(first_error.path, "foo");
255        assert_eq!(
256            first_error.error,
257            "Expected a string, but got: Value(Integer(42))"
258        );
259    }
260}