yaml_schema/schemas/
object.rs

1use std::collections::HashMap;
2
3use hashlink::LinkedHashMap;
4use log::debug;
5use regex::Regex;
6use saphyr::AnnotatedMapping;
7use saphyr::MarkedYaml;
8use saphyr::Scalar;
9use saphyr::YamlData;
10
11use crate::AnyOfSchema;
12use crate::BoolOrTypedSchema;
13use crate::Error;
14use crate::Reference;
15use crate::Result;
16use crate::StringSchema;
17use crate::TypedSchema;
18use crate::YamlSchema;
19use crate::loader::load_array_of_schemas_marked;
20use crate::loader::load_integer_marked;
21use crate::schemas::BaseSchema;
22use crate::schemas::SchemaMetadata;
23use crate::utils::format_marker;
24use crate::utils::hash_map;
25use crate::utils::linked_hash_map;
26
27/// An object schema
28#[derive(Debug, Default, PartialEq)]
29pub struct ObjectSchema {
30    pub base: BaseSchema,
31    pub metadata: Option<HashMap<String, String>>,
32    pub properties: Option<LinkedHashMap<String, YamlSchema>>,
33    pub required: Option<Vec<String>>,
34    pub additional_properties: Option<BoolOrTypedSchema>,
35    pub pattern_properties: Option<LinkedHashMap<String, YamlSchema>>,
36    pub property_names: Option<StringSchema>,
37    pub min_properties: Option<usize>,
38    pub max_properties: Option<usize>,
39    pub any_of: Option<AnyOfSchema>,
40}
41
42impl SchemaMetadata for ObjectSchema {
43    fn get_accepted_keys() -> &'static [&'static str] {
44        &[
45            "properties",
46            "required",
47            "additionalProperties",
48            "patternProperties",
49            "propertyNames",
50            "minProperties",
51            "maxProperties",
52            "anyOf",
53        ]
54    }
55}
56
57impl ObjectSchema {
58    pub fn from_base(base: BaseSchema) -> Self {
59        Self {
60            base,
61            ..Default::default()
62        }
63    }
64
65    pub fn builder() -> ObjectSchemaBuilder {
66        ObjectSchemaBuilder::new()
67    }
68}
69
70impl TryFrom<&MarkedYaml<'_>> for ObjectSchema {
71    type Error = crate::Error;
72
73    fn try_from(marked_yaml: &MarkedYaml<'_>) -> Result<Self> {
74        debug!("[ObjectSchema]: TryFrom {marked_yaml:?}");
75        if let YamlData::Mapping(mapping) = &marked_yaml.data {
76            Ok(ObjectSchema::try_from(mapping)?)
77        } else {
78            Err(expected_mapping!(marked_yaml))
79        }
80    }
81}
82
83impl TryFrom<&AnnotatedMapping<'_, MarkedYaml<'_>>> for ObjectSchema {
84    type Error = crate::Error;
85
86    fn try_from(mapping: &AnnotatedMapping<'_, MarkedYaml<'_>>) -> crate::Result<Self> {
87        let mut object_schema = ObjectSchema::from_base(BaseSchema::try_from(mapping)?);
88        for (key, value) in mapping.iter() {
89            if let YamlData::Value(Scalar::String(s)) = &key.data {
90                if object_schema.base.handle_key_value(s, value)?.is_none() {
91                    match s.as_ref() {
92                        "properties" => {
93                            let properties = load_properties_marked(value)?;
94                            object_schema.properties = Some(properties);
95                        }
96                        "additionalProperties" => {
97                            let additional_properties = load_additional_properties_marked(value)?;
98                            object_schema.additional_properties = Some(additional_properties);
99                        }
100                        "minProperties" => {
101                            object_schema.min_properties =
102                                Some(load_integer_marked(value)? as usize);
103                        }
104                        "maxProperties" => {
105                            object_schema.max_properties =
106                                Some(load_integer_marked(value)? as usize);
107                        }
108                        "patternProperties" => {
109                            let pattern_properties = load_properties_marked(value)?;
110                            object_schema.pattern_properties = Some(pattern_properties);
111                        }
112                        "propertyNames" => {
113                            if let YamlData::Mapping(mapping) = &value.data {
114                                let pattern_key = MarkedYaml::value_from_str("pattern");
115                                if !mapping.contains_key(&pattern_key) {
116                                    return Err(generic_error!(
117                                        "{} propertyNames: Missing required key: pattern",
118                                        format_marker(&value.span.start)
119                                    ));
120                                }
121                                if let YamlData::Value(Scalar::String(pattern)) =
122                                    &mapping.get(&pattern_key).unwrap().data
123                                {
124                                    let regex = Regex::new(pattern.as_ref()).map_err(|_e| {
125                                        Error::InvalidRegularExpression(pattern.to_string())
126                                    })?;
127                                    object_schema.property_names =
128                                        Some(StringSchema::builder().pattern(regex).build());
129                                }
130                            } else {
131                                return Err(unsupported_type!(
132                                    "propertyNames: Expected a mapping, but got: {:?}",
133                                    value
134                                ));
135                            }
136                        }
137                        "anyOf" => {
138                            let any_of = load_array_of_schemas_marked(value)?;
139                            let any_of_schema = AnyOfSchema { any_of };
140                            object_schema.any_of = Some(any_of_schema);
141                        }
142                        "required" => {
143                            if let YamlData::Sequence(values) = &value.data {
144                                let required = values
145                                    .iter()
146                                    .map(|v| {
147                                        if let YamlData::Value(Scalar::String(s)) = &v.data {
148                                            Ok(s.to_string())
149                                        } else {
150                                            Err(generic_error!(
151                                                "{} Expected a string, got {:?}",
152                                                format_marker(&v.span.start),
153                                                v
154                                            ))
155                                        }
156                                    })
157                                    .collect::<Result<Vec<String>>>()?;
158                                object_schema.required = Some(required);
159                            } else {
160                                return Err(unsupported_type!(
161                                    "required: Expected an array, but got: {:?}",
162                                    value
163                                ));
164                            }
165                        }
166                        // Maybe this should be handled by the base schema?
167                        "type" => {
168                            if let YamlData::Value(Scalar::String(s)) = &value.data {
169                                if s != "object" {
170                                    return Err(unsupported_type!(
171                                        "Expected type: object, but got: {}",
172                                        s
173                                    ));
174                                }
175                            } else {
176                                return Err(expected_type_is_string!(value));
177                            }
178                        }
179                        _ => {
180                            if s.starts_with("$") {
181                                if let YamlData::Value(Scalar::String(value)) = &key.data {
182                                    if object_schema.metadata.is_none() {
183                                        object_schema.metadata = Some(HashMap::new());
184                                    }
185                                    object_schema
186                                        .metadata
187                                        .as_mut()
188                                        .unwrap()
189                                        .insert(s.to_string(), value.to_string());
190                                } else {
191                                    return Err(generic_error!(
192                                        "{} Expected a string value but got {:?}",
193                                        format_marker(&value.span.start),
194                                        value.data
195                                    ));
196                                }
197                            } else {
198                                unimplemented!("Unsupported key for type: object: {}", s);
199                            }
200                        }
201                    }
202                }
203            } else {
204                return Err(generic_error!(
205                    "{} Expected a scalar key, got: {:#?}",
206                    format_marker(&key.span.start),
207                    key
208                ));
209            }
210        }
211        Ok(object_schema)
212    }
213}
214
215fn load_properties_marked(value: &MarkedYaml) -> Result<LinkedHashMap<String, YamlSchema>> {
216    if let YamlData::Mapping(mapping) = &value.data {
217        let mut properties = LinkedHashMap::new();
218        for (key, value) in mapping.iter() {
219            if let YamlData::Value(Scalar::String(key)) = &key.data {
220                if key.as_ref() == "$ref" {
221                    let reference: Reference = value.try_into()?;
222                    properties.insert(key.to_string(), YamlSchema::reference(reference));
223                } else if value.data.is_mapping() {
224                    let schema: YamlSchema = value.try_into()?;
225                    properties.insert(key.to_string(), schema);
226                } else {
227                    return Err(generic_error!(
228                        "properties: Expected a mapping for \"{}\", but got: {:?}",
229                        key,
230                        value
231                    ));
232                }
233            } else {
234                return Err(generic_error!(
235                    "{} Expected a string key, but got: {:?}",
236                    format_marker(&key.span.start),
237                    key
238                ));
239            }
240        }
241        Ok(properties)
242    } else {
243        Err(generic_error!(
244            "{} properties: expected a mapping, but got: {:#?}",
245            format_marker(&value.span.start),
246            value
247        ))
248    }
249}
250
251fn load_additional_properties_marked(marked_yaml: &MarkedYaml) -> Result<BoolOrTypedSchema> {
252    match &marked_yaml.data {
253        YamlData::Value(scalar) => match scalar {
254            Scalar::Boolean(b) => Ok(BoolOrTypedSchema::Boolean(*b)),
255            _ => Err(generic_error!(
256                "{} Expected a boolean scalar, but got: {:#?}",
257                format_marker(&marked_yaml.span.start),
258                scalar
259            )),
260        },
261        YamlData::Mapping(mapping) => {
262            let ref_key = MarkedYaml::value_from_str("$ref");
263            if mapping.contains_key(&ref_key) {
264                Ok(BoolOrTypedSchema::Reference(marked_yaml.try_into()?))
265            } else {
266                let schema: TypedSchema = marked_yaml.try_into()?;
267                Ok(BoolOrTypedSchema::TypedSchema(Box::new(schema)))
268            }
269        }
270        _ => Err(unsupported_type!(
271            "Expected type: boolean or mapping, but got: {:?}",
272            marked_yaml
273        )),
274    }
275}
276
277impl std::fmt::Display for ObjectSchema {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        write!(f, "Object {self:?}")
280    }
281}
282
283pub struct ObjectSchemaBuilder(ObjectSchema);
284
285impl Default for ObjectSchemaBuilder {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl ObjectSchemaBuilder {
292    pub fn new() -> Self {
293        Self(ObjectSchema::default())
294    }
295
296    pub fn build(&mut self) -> ObjectSchema {
297        std::mem::take(&mut self.0)
298    }
299
300    pub fn boxed(&mut self) -> Box<ObjectSchema> {
301        Box::new(self.build())
302    }
303
304    pub fn metadata<K, V>(&mut self, key: K, value: V) -> &mut Self
305    where
306        K: Into<String>,
307        V: Into<String>,
308    {
309        if let Some(metadata) = self.0.metadata.as_mut() {
310            metadata.insert(key.into(), value.into());
311        } else {
312            self.0.metadata = Some(hash_map(key.into(), value.into()));
313        }
314        self
315    }
316
317    pub fn properties(&mut self, properties: LinkedHashMap<String, YamlSchema>) -> &mut Self {
318        self.0.properties = Some(properties);
319        self
320    }
321
322    pub fn property<K>(&mut self, key: K, value: YamlSchema) -> &mut Self
323    where
324        K: Into<String>,
325    {
326        if let Some(properties) = self.0.properties.as_mut() {
327            properties.insert(key.into(), value);
328            self
329        } else {
330            self.properties(linked_hash_map(key.into(), value))
331        }
332    }
333
334    pub fn require<S>(&mut self, property_name: S) -> &mut Self
335    where
336        S: Into<String>,
337    {
338        if let Some(required) = self.0.required.as_mut() {
339            required.push(property_name.into());
340        } else {
341            self.0.required = Some(vec![property_name.into()]);
342        }
343        self
344    }
345
346    pub fn additional_properties(&mut self, additional_properties: bool) -> &mut Self {
347        self.0.additional_properties = Some(BoolOrTypedSchema::Boolean(additional_properties));
348        self
349    }
350
351    pub fn additional_property_types(&mut self, typed_schema: TypedSchema) -> &mut Self {
352        self.0.additional_properties = Some(BoolOrTypedSchema::TypedSchema(Box::new(typed_schema)));
353        self
354    }
355
356    pub fn pattern_properties(
357        &mut self,
358        pattern_properties: LinkedHashMap<String, YamlSchema>,
359    ) -> &mut Self {
360        self.0.pattern_properties = Some(pattern_properties);
361        self
362    }
363
364    pub fn pattern_property<K>(&mut self, key: K, value: YamlSchema) -> &mut Self
365    where
366        K: Into<String>,
367    {
368        if let Some(pattern_properties) = self.0.pattern_properties.as_mut() {
369            pattern_properties.insert(key.into(), value);
370            self
371        } else {
372            self.pattern_properties(linked_hash_map(key.into(), value))
373        }
374    }
375
376    pub fn property_names(&mut self, property_names: StringSchema) -> &mut Self {
377        self.0.property_names = Some(property_names);
378        self
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::Validator;
386    use saphyr::LoadableYamlNode;
387
388    #[test]
389    fn test_builder_default() {
390        let schema = ObjectSchema::builder().build();
391        assert_eq!(ObjectSchema::default(), schema);
392    }
393
394    #[test]
395    fn test_builder_metadata() {
396        let schema = ObjectSchema::builder()
397            .metadata("description", "The description")
398            .build();
399        assert!(schema.metadata.is_some());
400        assert_eq!(
401            schema.metadata.unwrap().get("description").unwrap(),
402            "The description"
403        );
404    }
405
406    #[test]
407    fn test_builder_properties() {
408        let schema = ObjectSchema::builder()
409            .property("type", YamlSchema::ref_str("schema_type"))
410            .build();
411        assert!(schema.properties.is_some());
412        assert_eq!(
413            *schema.properties.unwrap().get("type").unwrap(),
414            YamlSchema::ref_str("schema_type")
415        );
416    }
417
418    #[test]
419    fn test_additional_properties_as_schema() {
420        let docs = MarkedYaml::load_from_str(
421            "
422      type: object
423      properties:
424        number:
425          type: number
426        street_name:
427          type: string
428        street_type:
429          enum: [Street, Avenue, Boulevard]
430      additionalProperties:
431        type: string",
432        )
433        .unwrap();
434
435        let doc = docs.first().unwrap();
436
437        let schema: ObjectSchema = doc.try_into().unwrap();
438
439        let yaml_docs = MarkedYaml::load_from_str(
440            "
441number: 1600
442street_name: Pennsylvania
443street_type: Avenue
444office_number: 201",
445        )
446        .unwrap();
447
448        let yaml = yaml_docs.first().unwrap();
449
450        let context = crate::Context::default();
451        let result = schema.validate(&context, yaml);
452        if result.is_err() {
453            println!("{:?}", result.as_ref().unwrap());
454            panic!("Validation failed: {:?}", result.as_ref().unwrap());
455        }
456        assert!(context.has_errors());
457        for error in context.errors.as_ref().borrow().iter() {
458            println!("{error:?}");
459        }
460    }
461
462    #[test]
463    fn test_object_schema_with_description() {
464        let yaml = r#"
465        type: object
466        description: The description
467        "#;
468        let doc = MarkedYaml::load_from_str(yaml).unwrap();
469        let marked_yaml = doc.first().unwrap();
470        let object_schema = ObjectSchema::try_from(marked_yaml).unwrap();
471        assert_eq!(
472            object_schema.base.description,
473            Some("The description".to_string())
474        );
475    }
476}