Skip to main content

yaml_schema/schemas/
object.rs

1use std::collections::HashSet;
2use std::fmt::Display;
3
4use hashlink::LinkedHashMap;
5use log::debug;
6use regex::Regex;
7use saphyr::AnnotatedMapping;
8use saphyr::MarkedYaml;
9use saphyr::Scalar;
10use saphyr::YamlData;
11
12use crate::Error;
13use crate::Result;
14use crate::YamlSchema;
15use crate::loader::load_integer_marked;
16use crate::schemas::BooleanOrSchema;
17use crate::schemas::StringSchema;
18use crate::utils::format_annotated_mapping;
19use crate::utils::format_marker;
20use crate::utils::linked_hash_map;
21
22/// A pattern property entry: a pre-compiled regex paired with its schema.
23#[derive(Debug)]
24pub struct PatternProperty {
25    pub regex: Regex,
26    pub schema: YamlSchema,
27}
28
29impl PartialEq for PatternProperty {
30    fn eq(&self, other: &Self) -> bool {
31        self.regex.as_str() == other.regex.as_str() && self.schema == other.schema
32    }
33}
34
35/// An object schema
36#[derive(Debug, Default, PartialEq)]
37pub struct ObjectSchema {
38    pub properties: Option<LinkedHashMap<String, YamlSchema>>,
39    pub required: Option<Vec<String>>,
40    pub additional_properties: Option<BooleanOrSchema>,
41    pub pattern_properties: Option<Vec<PatternProperty>>,
42    pub property_names: Option<StringSchema>,
43    pub min_properties: Option<usize>,
44    pub max_properties: Option<usize>,
45    /// JSON Schema `dependentRequired`: when a trigger property is present, all listed properties must be present.
46    pub dependent_required: Option<LinkedHashMap<String, Vec<String>>>,
47    /// JSON Schema `dependentSchemas`: when a trigger property is present, the whole object must match the subschema.
48    pub dependent_schemas: Option<LinkedHashMap<String, YamlSchema>>,
49}
50
51impl ObjectSchema {
52    pub fn builder() -> ObjectSchemaBuilder {
53        ObjectSchemaBuilder::new()
54    }
55}
56
57impl<'r> TryFrom<&MarkedYaml<'r>> for ObjectSchema {
58    type Error = crate::Error;
59
60    fn try_from(marked_yaml: &MarkedYaml<'r>) -> Result<Self> {
61        debug!("[ObjectSchema]: TryFrom {marked_yaml:?}");
62        if let YamlData::Mapping(mapping) = &marked_yaml.data {
63            Ok(ObjectSchema::try_from(mapping)?)
64        } else {
65            Err(expected_mapping!(marked_yaml))
66        }
67    }
68}
69
70impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for ObjectSchema {
71    type Error = crate::Error;
72
73    fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
74        debug!(
75            "[ObjectSchema#try_from] Mapping: {}",
76            format_annotated_mapping(mapping)
77        );
78        let mut object_schema = ObjectSchema::default();
79        for (key, value) in mapping.iter() {
80            if let YamlData::Value(Scalar::String(s)) = &key.data {
81                match s.as_ref() {
82                    "properties" => {
83                        let properties = load_properties_marked(value)?;
84                        object_schema.properties = Some(properties);
85                    }
86                    "additionalProperties" => {
87                        let additional_properties = load_additional_properties_marked(value)?;
88                        object_schema.additional_properties = Some(additional_properties);
89                    }
90                    "minProperties" => {
91                        object_schema.min_properties = Some(load_integer_marked(value)? as usize);
92                    }
93                    "maxProperties" => {
94                        object_schema.max_properties = Some(load_integer_marked(value)? as usize);
95                    }
96                    "patternProperties" => {
97                        object_schema.pattern_properties =
98                            Some(load_pattern_properties_marked(value)?);
99                    }
100                    "propertyNames" => {
101                        if let YamlData::Mapping(mapping) = &value.data {
102                            let pattern_key = MarkedYaml::value_from_str("pattern");
103                            if !mapping.contains_key(&pattern_key) {
104                                return Err(generic_error!(
105                                    "{} propertyNames: Missing required key: pattern",
106                                    format_marker(&value.span.start)
107                                ));
108                            }
109                            if let Some(v) = &mapping.get(&pattern_key)
110                                && let YamlData::Value(Scalar::String(pattern)) = &v.data
111                            {
112                                let regex = Regex::new(pattern.as_ref()).map_err(|_e| {
113                                    Error::InvalidRegularExpression(pattern.to_string())
114                                })?;
115                                object_schema.property_names =
116                                    Some(StringSchema::builder().pattern(regex).build());
117                            }
118                        } else {
119                            return Err(unsupported_type!(
120                                "propertyNames: Expected a mapping, but got: {:?}",
121                                value
122                            ));
123                        }
124                    }
125                    "required" => {
126                        if let YamlData::Sequence(values) = &value.data {
127                            let required = values
128                                .iter()
129                                .map(|v| {
130                                    if let YamlData::Value(Scalar::String(s)) = &v.data {
131                                        Ok(s.to_string())
132                                    } else {
133                                        Err(generic_error!(
134                                            "{} Expected a string, got {:?}",
135                                            format_marker(&v.span.start),
136                                            v
137                                        ))
138                                    }
139                                })
140                                .collect::<Result<Vec<String>>>()?;
141                            object_schema.required = Some(required);
142                        } else {
143                            return Err(unsupported_type!(
144                                "required: Expected an array, but got: {:?}",
145                                value
146                            ));
147                        }
148                    }
149                    "dependentRequired" => {
150                        object_schema.dependent_required =
151                            Some(load_dependent_required_marked(value)?);
152                    }
153                    "dependentSchemas" => {
154                        object_schema.dependent_schemas =
155                            Some(load_dependent_schemas_marked(value)?);
156                    }
157                    "unevaluatedProperties" => {
158                        // Loaded on `Subschema`; ignore here when parsing `type: object` mapping.
159                    }
160                    // Maybe this should be handled by the base schema?
161                    "type" => {
162                        if let YamlData::Value(Scalar::String(s)) = &value.data {
163                            if s != "object" {
164                                return Err(unsupported_type!(
165                                    "Expected type: object, but got: {}",
166                                    s
167                                ));
168                            }
169                        } else {
170                            return Err(expected_type_is_string!(value));
171                        }
172                    }
173                    _ => {
174                        debug!("Unsupported key for type: object: {}", s);
175                    }
176                }
177            } else {
178                return Err(expected_scalar!(
179                    "{} Expected a scalar key, got: {:?}",
180                    format_marker(&key.span.start),
181                    key
182                ));
183            }
184        }
185        Ok(object_schema)
186    }
187}
188
189fn load_properties_marked<'r>(value: &MarkedYaml<'r>) -> Result<LinkedHashMap<String, YamlSchema>> {
190    if let YamlData::Mapping(mapping) = &value.data {
191        let mut properties = LinkedHashMap::new();
192        for (key, value) in mapping.iter() {
193            if let YamlData::Value(Scalar::String(key)) = &key.data {
194                if value.data.is_mapping() {
195                    let schema: YamlSchema = value.try_into()?;
196                    properties.insert(key.to_string(), schema);
197                } else {
198                    return Err(generic_error!(
199                        "properties: Expected a mapping for \"{}\", but got: {:?}",
200                        key,
201                        value
202                    ));
203                }
204            } else {
205                return Err(generic_error!(
206                    "{} Expected a string key, but got: {:?}",
207                    format_marker(&key.span.start),
208                    key
209                ));
210            }
211        }
212        Ok(properties)
213    } else {
214        Err(generic_error!(
215            "{} properties: expected a mapping, but got: {:?}",
216            format_marker(&value.span.start),
217            value
218        ))
219    }
220}
221
222fn load_pattern_properties_marked<'r>(value: &MarkedYaml<'r>) -> Result<Vec<PatternProperty>> {
223    if let YamlData::Mapping(mapping) = &value.data {
224        let mut pattern_properties = Vec::new();
225        for (key, value) in mapping.iter() {
226            if let YamlData::Value(Scalar::String(pattern)) = &key.data {
227                let regex = Regex::new(pattern.as_ref())
228                    .map_err(|_e| Error::InvalidRegularExpression(pattern.to_string()))?;
229                if value.data.is_mapping() {
230                    let schema: YamlSchema = value.try_into()?;
231                    pattern_properties.push(PatternProperty { regex, schema });
232                } else {
233                    return Err(generic_error!(
234                        "patternProperties: Expected a mapping for \"{}\", but got: {:?}",
235                        pattern,
236                        value
237                    ));
238                }
239            } else {
240                return Err(generic_error!(
241                    "{} Expected a string key, but got: {:?}",
242                    format_marker(&key.span.start),
243                    key
244                ));
245            }
246        }
247        Ok(pattern_properties)
248    } else {
249        Err(generic_error!(
250            "{} patternProperties: expected a mapping, but got: {:?}",
251            format_marker(&value.span.start),
252            value
253        ))
254    }
255}
256
257fn load_dependent_required_marked<'r>(
258    value: &MarkedYaml<'r>,
259) -> Result<LinkedHashMap<String, Vec<String>>> {
260    if let YamlData::Mapping(mapping) = &value.data {
261        let mut out = LinkedHashMap::new();
262        for (key, val) in mapping.iter() {
263            let YamlData::Value(Scalar::String(trigger)) = &key.data else {
264                return Err(generic_error!(
265                    "{} dependentRequired: Expected string key, got: {:?}",
266                    format_marker(&key.span.start),
267                    key.data
268                ));
269            };
270            let YamlData::Sequence(values) = &val.data else {
271                return Err(unsupported_type!(
272                    "{} dependentRequired: Expected array for key {:?}, got: {:?}",
273                    format_marker(&val.span.start),
274                    trigger.as_ref(),
275                    val.data
276                ));
277            };
278            let mut deps = Vec::new();
279            let mut seen = HashSet::new();
280            for v in values {
281                let YamlData::Value(Scalar::String(s)) = &v.data else {
282                    return Err(generic_error!(
283                        "{} dependentRequired: Expected string in array, got: {:?}",
284                        format_marker(&v.span.start),
285                        v.data
286                    ));
287                };
288                let dep = s.to_string();
289                if !seen.insert(dep.clone()) {
290                    return Err(generic_error!(
291                        "{} dependentRequired: duplicate property name {:?} for trigger {:?}",
292                        format_marker(&v.span.start),
293                        dep,
294                        trigger.as_ref()
295                    ));
296                }
297                deps.push(dep);
298            }
299            out.insert(trigger.to_string(), deps);
300        }
301        Ok(out)
302    } else {
303        Err(generic_error!(
304            "{} dependentRequired: expected a mapping, but got: {:?}",
305            format_marker(&value.span.start),
306            value.data
307        ))
308    }
309}
310
311fn load_dependent_schemas_marked<'r>(
312    value: &MarkedYaml<'r>,
313) -> Result<LinkedHashMap<String, YamlSchema>> {
314    if let YamlData::Mapping(mapping) = &value.data {
315        let mut out = LinkedHashMap::new();
316        for (key, val) in mapping.iter() {
317            let YamlData::Value(Scalar::String(name)) = &key.data else {
318                return Err(generic_error!(
319                    "{} dependentSchemas: Expected string key, got: {:?}",
320                    format_marker(&key.span.start),
321                    key.data
322                ));
323            };
324            if !val.data.is_mapping() {
325                return Err(generic_error!(
326                    "dependentSchemas: Expected a mapping for {:?}, but got: {:?}",
327                    name.as_ref(),
328                    val.data
329                ));
330            }
331            let schema: YamlSchema = val.try_into()?;
332            out.insert(name.to_string(), schema);
333        }
334        Ok(out)
335    } else {
336        Err(generic_error!(
337            "{} dependentSchemas: expected a mapping, but got: {:?}",
338            format_marker(&value.span.start),
339            value.data
340        ))
341    }
342}
343
344fn load_additional_properties_marked<'r>(marked_yaml: &MarkedYaml<'r>) -> Result<BooleanOrSchema> {
345    match &marked_yaml.data {
346        YamlData::Value(scalar) => match scalar {
347            Scalar::Boolean(b) => Ok(BooleanOrSchema::Boolean(*b)),
348            _ => Err(generic_error!(
349                "{} Expected a boolean scalar, but got: {:?}",
350                format_marker(&marked_yaml.span.start),
351                scalar
352            )),
353        },
354        YamlData::Mapping(_mapping) => marked_yaml.try_into().map(BooleanOrSchema::schema),
355        _ => Err(unsupported_type!(
356            "Expected type: boolean or mapping, but got: {:?}",
357            marked_yaml
358        )),
359    }
360}
361
362impl Display for ObjectSchema {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        write!(f, "Object {self:?}")
365    }
366}
367
368pub struct ObjectSchemaBuilder(ObjectSchema);
369
370impl Default for ObjectSchemaBuilder {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376impl ObjectSchemaBuilder {
377    pub fn new() -> Self {
378        Self(ObjectSchema::default())
379    }
380
381    pub fn build(&mut self) -> ObjectSchema {
382        std::mem::take(&mut self.0)
383    }
384
385    pub fn boxed(&mut self) -> Box<ObjectSchema> {
386        Box::new(self.build())
387    }
388
389    pub fn properties(&mut self, properties: LinkedHashMap<String, YamlSchema>) -> &mut Self {
390        self.0.properties = Some(properties);
391        self
392    }
393
394    pub fn property<K>(&mut self, key: K, value: YamlSchema) -> &mut Self
395    where
396        K: Into<String>,
397    {
398        if let Some(properties) = self.0.properties.as_mut() {
399            properties.insert(key.into(), value);
400            self
401        } else {
402            self.properties(linked_hash_map(key.into(), value))
403        }
404    }
405
406    pub fn require<S>(&mut self, property_name: S) -> &mut Self
407    where
408        S: Into<String>,
409    {
410        if let Some(required) = self.0.required.as_mut() {
411            required.push(property_name.into());
412        } else {
413            self.0.required = Some(vec![property_name.into()]);
414        }
415        self
416    }
417
418    pub fn additional_properties(&mut self, additional_properties: bool) -> &mut Self {
419        self.0.additional_properties = Some(BooleanOrSchema::Boolean(additional_properties));
420        self
421    }
422
423    pub fn additional_property_types(&mut self, typed_schema: YamlSchema) -> &mut Self {
424        self.0.additional_properties = Some(BooleanOrSchema::schema(typed_schema));
425        self
426    }
427
428    pub fn pattern_properties(&mut self, pattern_properties: Vec<PatternProperty>) -> &mut Self {
429        self.0.pattern_properties = Some(pattern_properties);
430        self
431    }
432
433    /// Add a pattern property, compiling the regex pattern at build time.
434    ///
435    /// # Panics
436    /// Panics if `pattern` is not a valid regex.
437    pub fn pattern_property<K>(&mut self, pattern: K, schema: YamlSchema) -> &mut Self
438    where
439        K: AsRef<str>,
440    {
441        let regex = Regex::new(pattern.as_ref())
442            .unwrap_or_else(|e| panic!("Invalid regex pattern '{}': {e}", pattern.as_ref()));
443        let entry = PatternProperty { regex, schema };
444        if let Some(pattern_properties) = self.0.pattern_properties.as_mut() {
445            pattern_properties.push(entry);
446        } else {
447            self.0.pattern_properties = Some(vec![entry]);
448        }
449        self
450    }
451
452    pub fn property_names(&mut self, property_names: StringSchema) -> &mut Self {
453        self.0.property_names = Some(property_names);
454        self
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::{Validator, loader};
462    use saphyr::LoadableYamlNode;
463
464    #[test]
465    fn test_builder_default() {
466        let schema = ObjectSchema::builder().build();
467        assert_eq!(ObjectSchema::default(), schema);
468    }
469
470    #[test]
471    fn test_builder_properties() {
472        let schema = ObjectSchema::builder()
473            .property("type", YamlSchema::ref_str("schema_type"))
474            .build();
475        assert!(schema.properties.is_some());
476        assert_eq!(
477            *schema.properties.unwrap().get("type").unwrap(),
478            YamlSchema::ref_str("schema_type")
479        );
480    }
481
482    #[test]
483    fn test_additional_properties_as_schema() {
484        let docs = MarkedYaml::load_from_str(
485            "
486      type: object
487      properties:
488        number:
489          type: number
490        street_name:
491          type: string
492        street_type:
493          enum: [Street, Avenue, Boulevard]
494      additionalProperties:
495        type: string",
496        )
497        .unwrap();
498
499        let doc = docs.first().unwrap();
500
501        let schema: ObjectSchema = doc.try_into().unwrap();
502
503        let yaml_docs = MarkedYaml::load_from_str(
504            "
505number: 1600
506street_name: Pennsylvania
507street_type: Avenue
508office_number: 201",
509        )
510        .unwrap();
511
512        let yaml = yaml_docs.first().unwrap();
513
514        let context = crate::Context::default();
515        let result = schema.validate(&context, yaml);
516        assert!(result.is_ok(), "Validation failed: {result:?}");
517
518        assert!(context.has_errors());
519    }
520
521    #[test]
522    fn test_object_schema_with_description() {
523        let yaml = r#"
524        type: object
525        description: The description
526        "#;
527        let doc = MarkedYaml::load_from_str(yaml).unwrap();
528        let marked_yaml = doc.first().unwrap();
529        let yaml_schema = YamlSchema::try_from(marked_yaml).unwrap();
530        let YamlSchema::Subschema(object_schema) = &yaml_schema else {
531            panic!("Expected Subschema, but got: {:?}", &yaml_schema);
532        };
533        assert_eq!(
534            object_schema.metadata_and_annotations.description,
535            Some("The description".to_string())
536        );
537    }
538
539    #[test]
540    fn test_object_schema_with_const_property() {
541        let yaml = r#"
542        type: object
543        properties:
544          const:
545            type:
546              - string
547              - integer
548              - number
549              - boolean
550        "#;
551        let root_schema = loader::load_from_str(yaml).unwrap();
552        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
553            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
554        };
555        let Some(object_schema) = &subschema.object_schema else {
556            panic!(
557                "Expected ObjectSchema, but got: {:?}",
558                &subschema.object_schema
559            );
560        };
561        // Verify properties were loaded correctly
562        assert!(
563            object_schema
564                .properties
565                .as_ref()
566                .unwrap()
567                .contains_key("const")
568        );
569    }
570
571    #[test]
572    fn test_dependent_required_loads() {
573        let yaml = r#"
574        type: object
575        dependentRequired:
576          a:
577            - b
578            - c
579        "#;
580        let doc = MarkedYaml::load_from_str(yaml).unwrap();
581        let os: ObjectSchema = doc.first().unwrap().try_into().unwrap();
582        let dr = os.dependent_required.as_ref().unwrap();
583        assert_eq!(dr.get("a"), Some(&vec!["b".to_string(), "c".to_string()]));
584    }
585
586    #[test]
587    fn test_dependent_required_rejects_duplicate_dep() {
588        let yaml = r#"
589        type: object
590        dependentRequired:
591          a:
592            - b
593            - b
594        "#;
595        let doc = MarkedYaml::load_from_str(yaml).unwrap();
596        let err = ObjectSchema::try_from(doc.first().unwrap()).unwrap_err();
597        assert!(
598            err.to_string().contains("duplicate property name"),
599            "unexpected: {err}"
600        );
601    }
602
603    #[test]
604    fn test_dependent_schemas_loads() {
605        let yaml = r#"
606        type: object
607        dependentSchemas:
608          foo:
609            type: object
610            required:
611              - bar
612        "#;
613        let doc = MarkedYaml::load_from_str(yaml).unwrap();
614        let os: ObjectSchema = doc.first().unwrap().try_into().unwrap();
615        assert!(os.dependent_schemas.is_some());
616        let ds = os.dependent_schemas.as_ref().unwrap();
617        assert!(ds.contains_key("foo"));
618    }
619}