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#[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#[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 pub dependent_required: Option<LinkedHashMap<String, Vec<String>>>,
47 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 }
160 "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 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 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}