yaml_schema/schemas/
array.rs

1use log::debug;
2
3use saphyr::AnnotatedMapping;
4use saphyr::MarkedYaml;
5use saphyr::Scalar;
6use saphyr::YamlData;
7
8use crate::BoolOrTypedSchema;
9use crate::Context;
10use crate::Reference;
11use crate::Result;
12use crate::TypedSchema;
13use crate::Validator;
14use crate::YamlSchema;
15use crate::loader;
16use crate::schemas::SchemaMetadata;
17use crate::utils::format_marker;
18use crate::utils::format_vec;
19use crate::utils::format_yaml_data;
20
21/// An array schema represents an array
22#[derive(Debug, Default, PartialEq)]
23pub struct ArraySchema {
24    pub items: Option<BoolOrTypedSchema>,
25    pub prefix_items: Option<Vec<YamlSchema>>,
26    pub contains: Option<Box<YamlSchema>>,
27}
28
29impl SchemaMetadata for ArraySchema {
30    fn get_accepted_keys() -> &'static [&'static str] {
31        &["items", "prefixItems", "contains"]
32    }
33}
34
35impl ArraySchema {
36    pub fn with_items_typed(typed_schema: TypedSchema) -> Self {
37        Self {
38            items: Some(BoolOrTypedSchema::TypedSchema(Box::new(typed_schema))),
39            ..Default::default()
40        }
41    }
42
43    pub fn with_items_ref(reference: Reference) -> Self {
44        Self {
45            items: Some(BoolOrTypedSchema::Reference(reference)),
46            ..Default::default()
47        }
48    }
49}
50
51impl TryFrom<&AnnotatedMapping<'_, MarkedYaml<'_>>> for ArraySchema {
52    type Error = crate::Error;
53
54    fn try_from(mapping: &AnnotatedMapping<'_, MarkedYaml<'_>>) -> crate::Result<Self> {
55        let mut array_schema = ArraySchema::default();
56        for (key, value) in mapping.iter() {
57            if let YamlData::Value(Scalar::String(s)) = &key.data {
58                match s.as_ref() {
59                    "contains" => {
60                        if value.data.is_mapping() {
61                            let yaml_schema = value.try_into()?;
62                            array_schema.contains = Some(Box::new(yaml_schema));
63                        } else {
64                            return Err(generic_error!(
65                                "contains: expected a mapping, but got: {:#?}",
66                                value
67                            ));
68                        }
69                    }
70                    "items" => {
71                        let array_items = loader::load_array_items_marked(value)?;
72                        array_schema.items = Some(array_items);
73                    }
74                    "type" => {
75                        if let YamlData::Value(Scalar::String(s)) = &value.data {
76                            if s != "array" {
77                                return Err(unsupported_type!(
78                                    "Expected type: array, but got: {}",
79                                    s
80                                ));
81                            }
82                        } else {
83                            return Err(expected_type_is_string!(value));
84                        }
85                    }
86                    "prefixItems" => {
87                        let prefix_items = loader::load_array_of_schemas_marked(value)?;
88                        array_schema.prefix_items = Some(prefix_items);
89                    }
90                    _ => unimplemented!("Unsupported key for ArraySchema: {}", s),
91                }
92            } else {
93                return Err(generic_error!(
94                    "{} Expected scalar key, got: {:?}",
95                    format_marker(&key.span.start),
96                    key
97                ));
98            }
99        }
100        Ok(array_schema)
101    }
102}
103
104impl Validator for ArraySchema {
105    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
106        debug!("[ArraySchema] self: {self:?}");
107        let data = &value.data;
108        debug!("[ArraySchema] Validating value: {}", format_yaml_data(data));
109
110        if let saphyr::YamlData::Sequence(array) = data {
111            // validate contains
112            if let Some(sub_schema) = &self.contains {
113                let any_matches = array.iter().any(|item| {
114                    let sub_context = crate::Context {
115                        root_schema: context.root_schema,
116                        fail_fast: true,
117                        ..Default::default()
118                    };
119                    sub_schema.validate(&sub_context, item).is_ok() && !sub_context.has_errors()
120                });
121                if !any_matches {
122                    context.add_error(value, "Contains validation failed!".to_string());
123                }
124            }
125
126            // validate prefix items
127            if let Some(prefix_items) = &self.prefix_items {
128                debug!(
129                    "[ArraySchema] Validating prefix items: {}",
130                    format_vec(prefix_items)
131                );
132                for (i, item) in array.iter().enumerate() {
133                    // if the index is within the prefix items, validate against the prefix items schema
134                    if i < prefix_items.len() {
135                        debug!(
136                            "[ArraySchema] Validating prefix item {} with schema: {}",
137                            i, prefix_items[i]
138                        );
139                        prefix_items[i].validate(context, item)?;
140                    } else if let Some(items) = &self.items {
141                        // if the index is not within the prefix items, validate against the array items schema
142                        debug!("[ArraySchema] Validating array item {i} with schema: {items}");
143                        match items {
144                            BoolOrTypedSchema::Boolean(true) => {
145                                // `items: true` allows any items
146                                break;
147                            }
148                            BoolOrTypedSchema::Boolean(false) => {
149                                context.add_error(
150                                    item,
151                                    "Additional array items are not allowed!".to_string(),
152                                );
153                            }
154                            BoolOrTypedSchema::TypedSchema(typed_schema) => {
155                                typed_schema.validate(context, item)?;
156                            }
157                            BoolOrTypedSchema::Reference(reference) => {
158                                // Grab the reference from the root schema.
159                                let Some(root) = &context.root_schema else {
160                                    context.add_error(
161                                        item,
162                                        "No root schema was provided to look up references"
163                                            .to_string(),
164                                    );
165                                    continue;
166                                };
167                                let Some(def) = root.get_def(&reference.ref_name) else {
168                                    context.add_error(
169                                        item,
170                                        format!("No definition for {} found", reference.ref_name),
171                                    );
172                                    continue;
173                                };
174                                def.validate(context, item)?;
175                            }
176                        }
177                    } else {
178                        break;
179                    }
180                }
181            } else {
182                // validate array items
183                if let Some(items) = &self.items {
184                    match items {
185                        BoolOrTypedSchema::Boolean(true) => { /* no-op */ }
186                        BoolOrTypedSchema::Boolean(false) => {
187                            if self.prefix_items.is_none() && !array.is_empty() {
188                                context.add_error(
189                                    array.first().unwrap(),
190                                    "Array items are not allowed!".to_string(),
191                                );
192                            }
193                        }
194                        BoolOrTypedSchema::TypedSchema(typed_schema) => {
195                            for item in array {
196                                typed_schema.validate(context, item)?;
197                            }
198                        }
199                        BoolOrTypedSchema::Reference(reference) => {
200                            // Grab the reference from the root schema.
201                            let Some(root) = &context.root_schema else {
202                                context.add_error(
203                                    array.first().unwrap(),
204                                    "No root schema was provided to look up references".to_string(),
205                                );
206                                return Ok(());
207                            };
208                            let Some(def) = root.get_def(&reference.ref_name) else {
209                                context.add_error(
210                                    array.first().unwrap(),
211                                    format!("No definition for {} found", reference.ref_name),
212                                );
213                                return Ok(());
214                            };
215                            for item in array {
216                                def.validate(context, item)?;
217                            }
218                        }
219                    }
220                }
221            }
222
223            Ok(())
224        } else {
225            debug!("[ArraySchema] context.fail_fast: {}", context.fail_fast);
226            context.add_error(
227                value,
228                format!(
229                    "Expected an array, but got: {}",
230                    format_yaml_data(&value.data)
231                ),
232            );
233            fail_fast!(context);
234            Ok(())
235        }
236    }
237}
238
239impl std::fmt::Display for ArraySchema {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        write!(
242            f,
243            "Array{{ items: {:?}, prefix_items: {:?}, contains: {:?}}}",
244            self.items, self.prefix_items, self.contains
245        )
246    }
247}
248#[cfg(test)]
249mod tests {
250    use crate::NumberSchema;
251    use crate::Schema;
252    use crate::StringSchema;
253    use crate::TypedSchema;
254    use saphyr::LoadableYamlNode;
255
256    use super::*;
257
258    #[test]
259    fn test_array_schema_prefix_items() {
260        let schema = ArraySchema {
261            prefix_items: Some(vec![YamlSchema::from(Schema::typed_number(
262                NumberSchema::default(),
263            ))]),
264            items: Some(BoolOrTypedSchema::TypedSchema(Box::new(
265                TypedSchema::string(StringSchema::default()),
266            ))),
267            ..Default::default()
268        };
269        let s = r#"
270        - 1
271        - 2
272        - Washington
273        "#;
274        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
275        let value = docs.first().unwrap();
276        let context = crate::Context::default();
277        let result = schema.validate(&context, value);
278        if result.is_err() {
279            println!("{}", result.unwrap_err());
280        }
281    }
282
283    #[test]
284    fn test_array_schema_prefix_items_from_yaml() {
285        let schema_string = "
286      type: array
287      prefixItems:
288        - type: number
289        - type: string
290        - enum:
291          - Street
292          - Avenue
293          - Boulevard
294        - enum:
295          - NW
296          - NE
297          - SW
298          - SE
299      items:
300        type: string
301";
302
303        let yaml_string = r#"
304        - 1600
305        - Pennsylvania
306        - Avenue
307        - NW
308        - Washington
309        "#;
310
311        let s_docs = saphyr::MarkedYaml::load_from_str(schema_string).unwrap();
312        let first_schema = s_docs.first().unwrap();
313        if let YamlData::Mapping(mapping) = &first_schema.data {
314            let schema = ArraySchema::try_from(mapping).unwrap();
315            let docs = saphyr::MarkedYaml::load_from_str(yaml_string).unwrap();
316            let value = docs.first().unwrap();
317            let context = crate::Context::default();
318            let result = schema.validate(&context, value);
319            if result.is_err() {
320                println!("{}", result.unwrap_err());
321            }
322        } else {
323            panic!("Expected first_schema to be a Mapping, but got {first_schema:?}");
324        }
325    }
326
327    #[test]
328    fn array_schema_prefix_items_with_additional_items() {
329        let schema_string = "
330      type: array
331      prefixItems:
332        - type: number
333        - type: string
334        - enum:
335          - Street
336          - Avenue
337          - Boulevard
338        - enum:
339          - NW
340          - NE
341          - SW
342          - SE
343      items:
344        type: string
345";
346
347        let yaml_string = r#"
348        - 1600
349        - Pennsylvania
350        - Avenue
351        - NW
352        - 20500
353        "#;
354
355        let docs = MarkedYaml::load_from_str(schema_string).unwrap();
356        let first_doc = docs.first().unwrap();
357        if let YamlData::Mapping(mapping) = &first_doc.data {
358            let schema: ArraySchema = ArraySchema::try_from(mapping).unwrap();
359            let docs = saphyr::MarkedYaml::load_from_str(yaml_string).unwrap();
360            let value = docs.first().unwrap();
361            let context = crate::Context::default();
362            let result = schema.validate(&context, value);
363            if result.is_err() {
364                println!("{}", result.unwrap_err());
365            }
366        } else {
367            panic!("Expected first_doc to be a Mapping, but got {first_doc:?}");
368        }
369    }
370
371    #[test]
372    fn test_contains() {
373        let number_schema = YamlSchema::from(Schema::typed_number(NumberSchema::default()));
374        let schema = ArraySchema {
375            contains: Some(Box::new(number_schema)),
376            ..Default::default()
377        };
378        let s = r#"
379        - life
380        - universe
381        - everything
382        - 42
383        "#;
384        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
385        let value = docs.first().unwrap();
386        let context = crate::Context::default();
387        let result = schema.validate(&context, value);
388        assert!(result.is_ok());
389        let errors = context.errors.take();
390        assert!(errors.is_empty());
391    }
392
393    #[test]
394    fn test_array_schema_contains_fails() {
395        let number_schema = YamlSchema::from(Schema::typed_number(NumberSchema::default()));
396        let schema = ArraySchema {
397            contains: Some(Box::new(number_schema)),
398            ..Default::default()
399        };
400        let s = r#"
401        - life
402        - universe
403        - everything
404        "#;
405        let docs = saphyr::MarkedYaml::load_from_str(s).unwrap();
406        let value = docs.first().unwrap();
407        let context = crate::Context::default();
408        let result = schema.validate(&context, value);
409        assert!(result.is_ok());
410        let errors = context.errors.take();
411        assert!(!errors.is_empty());
412    }
413}