yaml_schema/validation/
objects.rs

1// A module to contain object type validation logic
2use hashlink::LinkedHashMap;
3use log::{debug, error};
4
5use crate::BoolOrTypedSchema;
6use crate::Error;
7use crate::ObjectSchema;
8use crate::Result;
9use crate::Validator;
10use crate::YamlSchema;
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: &BoolOrTypedSchema,
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        BoolOrTypedSchema::Boolean(true) => { /* noop */ }
65        // if additional_properties: false, then no additional properties are allowed
66        BoolOrTypedSchema::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        BoolOrTypedSchema::TypedSchema(schema) => {
76            schema.validate(&sub_context, value)?;
77        }
78        BoolOrTypedSchema::Reference(reference) => {
79            // Grab the reference from the root schema.
80            let Some(root) = &context.root_schema else {
81                context.add_error(
82                    value,
83                    "No root schema was provided to look up references".to_string(),
84                );
85                return Ok(false);
86            };
87            let Some(def) = root.get_def(&reference.ref_name) else {
88                context.add_error(
89                    value,
90                    format!("No definition for {} found", reference.ref_name),
91                );
92                return Ok(false);
93            };
94
95            def.validate(context, value)?;
96        }
97    }
98    Ok(true)
99}
100
101impl ObjectSchema {
102    fn validate_object_mapping<'a>(
103        &self,
104        context: &Context,
105        object: &saphyr::MarkedYaml,
106        mapping: &saphyr::AnnotatedMapping<'a, saphyr::MarkedYaml<'a>>,
107    ) -> Result<()> {
108        for (k, value) in mapping {
109            let key_string = match &k.data {
110                saphyr::YamlData::Value(scalar) => scalar_to_string(scalar),
111                v => {
112                    return Err(expected_scalar!(
113                        "[{}] Expected a scalar key, got: {:?}",
114                        format_marker(&k.span.start),
115                        v
116                    ));
117                }
118            };
119            let span = &k.span;
120            debug!("validate_object_mapping: key: \"{key_string}\"");
121            debug!(
122                "validate_object_mapping: span.start: {:?}",
123                format_marker(&span.start)
124            );
125            debug!(
126                "validate_object_mapping: span.end: {:?}",
127                format_marker(&span.end)
128            );
129            // First, we check the explicitly defined properties, and validate against it if found
130            if let Some(properties) = &self.properties
131                && try_validate_value_against_properties(context, &key_string, value, properties)?
132            {
133                continue;
134            }
135
136            // Then, we check if additional properties are allowed or not
137            if let Some(additional_properties) = &self.additional_properties {
138                try_validate_value_against_additional_properties(
139                    context,
140                    &key_string,
141                    value,
142                    additional_properties,
143                )?;
144            }
145
146            // Then we check if pattern_properties matches
147            if let Some(pattern_properties) = &self.pattern_properties {
148                for (pattern, schema) in pattern_properties {
149                    log::debug!("pattern: {pattern}");
150                    // TODO: compile the regex once instead of every time we're evaluating
151                    let re = regex::Regex::new(pattern).map_err(|e| {
152                        Error::GenericError(format!("Invalid regular expression pattern: {e}"))
153                    })?;
154                    if re.is_match(key_string.as_ref()) {
155                        schema.validate(context, value)?;
156                    }
157                }
158            }
159            // Finally, we check if it matches property_names
160            if let Some(property_names) = &self.property_names {
161                if let Some(re) = &property_names.pattern {
162                    debug!("Regex for property names: {}", re.as_str());
163                    if !re.is_match(key_string.as_ref()) {
164                        context.add_error(
165                            k,
166                            format!(
167                                "Property name '{}' does not match pattern '{}'",
168                                key_string,
169                                re.as_str()
170                            ),
171                        );
172                        fail_fast!(context)
173                    }
174                } else {
175                    return Err(Error::GenericError(
176                        "Expected a pattern for `property_names`".to_string(),
177                    ));
178                }
179            }
180        }
181        // If we have any AnyOf specification, check the object format against one of them.
182        if let Some(any_of) = &self.any_of {
183            any_of.validate(context, object)?;
184        }
185
186        // Validate required properties
187        if let Some(required) = &self.required {
188            for required_property in required {
189                if !mapping
190                    .keys()
191                    .map(|k| k.data.as_str().unwrap())
192                    .any(|s| s == required_property)
193                {
194                    context.add_error(
195                        object,
196                        format!("Required property '{required_property}' is missing!"),
197                    );
198                    fail_fast!(context)
199                }
200            }
201        }
202
203        // Validate minProperties
204        if let Some(min_properties) = &self.min_properties
205            && mapping.len() < *min_properties
206        {
207            context.add_error(
208                object,
209                format!("Object has too few properties! Minimum is {min_properties}!"),
210            );
211            fail_fast!(context)
212        }
213        // Validate maxProperties
214        if let Some(max_properties) = &self.max_properties
215            && mapping.len() > *max_properties
216        {
217            context.add_error(
218                object,
219                format!("Object has too many properties! Maximum is {max_properties}!"),
220            );
221            fail_fast!(context)
222        }
223
224        Ok(())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use crate::NumberSchema;
231    use crate::RootSchema;
232    use crate::Schema;
233    use crate::StringSchema;
234    use crate::engine;
235    use hashlink::LinkedHashMap;
236
237    use super::*;
238
239    #[test]
240    fn test_should_validate_properties() {
241        let mut properties = LinkedHashMap::new();
242        properties.insert(
243            "foo".to_string(),
244            YamlSchema::from(Schema::typed_string(StringSchema::default())),
245        );
246        properties.insert(
247            "bar".to_string(),
248            YamlSchema::from(Schema::typed_number(NumberSchema::default())),
249        );
250        let object_schema = ObjectSchema {
251            properties: Some(properties),
252            ..Default::default()
253        };
254        let root_schema = RootSchema::new_with_schema(Schema::typed_object(object_schema));
255        let value = r#"
256            foo: "I'm a string"
257            bar: 42
258        "#;
259        let result = engine::Engine::evaluate(&root_schema, value, true);
260        assert!(result.is_ok());
261
262        let value2 = r#"
263            foo: 42
264            baz: "I'm a string"
265        "#;
266        let context = engine::Engine::evaluate(&root_schema, value2, true).unwrap();
267        assert!(context.has_errors());
268        let errors = context.errors.borrow();
269        let first_error = errors.first().unwrap();
270        assert_eq!(first_error.path, "foo");
271        assert_eq!(
272            first_error.error,
273            "Expected a string, but got: Value(Integer(42))"
274        );
275    }
276}