1use serde::{Deserialize, Serialize};
30use std::collections::BTreeMap;
31
32pub mod keywords {
38 pub const DEFINITIONS_REF_PREFIX: &str = "#/definitions/";
42
43 pub const PROPERTIES_PATH_PREFIX: &str = "/properties/";
46
47 pub const REF: &str = "$ref";
51
52 pub const PROPERTIES: &str = "properties";
58 pub const DEFINITIONS: &str = "definitions";
59 pub const PATTERN_PROPERTIES: &str = "patternProperties";
60
61 pub const SCHEMA_MAPS: &[&str] = &[PROPERTIES, DEFINITIONS, PATTERN_PROPERTIES];
64
65 pub const ALL_OF: &str = "allOf";
71 pub const ANY_OF: &str = "anyOf";
72 pub const ONE_OF: &str = "oneOf";
73
74 pub const COMPOSITION: &[&str] = &[ALL_OF, ANY_OF, ONE_OF];
76
77 pub const IF: &str = "if";
83 pub const THEN: &str = "then";
84 pub const ELSE: &str = "else";
85
86 pub const CONDITIONALS: &[&str] = &[IF, THEN, ELSE];
89
90 pub const READ_ONLY_PROPERTIES: &str = "readOnlyProperties";
96 pub const WRITE_ONLY_PROPERTIES: &str = "writeOnlyProperties";
97 pub const CREATE_ONLY_PROPERTIES: &str = "createOnlyProperties";
98 pub const DEPRECATED_PROPERTIES: &str = "deprecatedProperties";
99 pub const CONDITIONAL_CREATE_ONLY_PROPERTIES: &str = "conditionalCreateOnlyProperties";
100 pub const PRIMARY_IDENTIFIER: &str = "primaryIdentifier";
101
102 pub const METADATA_POINTER_ARRAYS: &[&str] = &[
104 READ_ONLY_PROPERTIES,
105 WRITE_ONLY_PROPERTIES,
106 CREATE_ONLY_PROPERTIES,
107 DEPRECATED_PROPERTIES,
108 CONDITIONAL_CREATE_ONLY_PROPERTIES,
109 PRIMARY_IDENTIFIER,
110 ];
111
112 pub const REQUIRED: &str = "required";
117 pub const REQUIRED_OR: &str = "requiredOr";
118 pub const REQUIRED_XOR: &str = "requiredXor";
119
120 pub const STRING_ARRAY_CONSTRAINTS: &[&str] = &[REQUIRED, REQUIRED_OR, REQUIRED_XOR];
123
124 pub const DEPENDENT_REQUIRED: &str = "dependentRequired";
129 pub const DEPENDENT_EXCLUDED: &str = "dependentExcluded";
130
131 pub const DEPENDENCY_MAPS: &[&str] = &[DEPENDENT_REQUIRED, DEPENDENT_EXCLUDED];
133
134 pub const ENUM: &str = "enum";
137 pub const ENUM_CASE_INSENSITIVE: &str = "enumCaseInsensitive";
138 pub const NOT: &str = "not";
139
140 pub const ENUM_KEYWORDS: &[&str] = &[ENUM, ENUM_CASE_INSENSITIVE];
142
143 pub const MINIMUM: &str = "minimum";
146 pub const MAXIMUM: &str = "maximum";
147 pub const EXCLUSIVE_MINIMUM: &str = "exclusiveMinimum";
148 pub const EXCLUSIVE_MAXIMUM: &str = "exclusiveMaximum";
149
150 pub const NUMERIC_CONSTRAINTS: &[&str] = &[MINIMUM, MAXIMUM, EXCLUSIVE_MINIMUM, EXCLUSIVE_MAXIMUM];
152
153 pub const MIN_LENGTH: &str = "minLength";
156 pub const MAX_LENGTH: &str = "maxLength";
157 pub const MIN_ITEMS: &str = "minItems";
158 pub const MAX_ITEMS: &str = "maxItems";
159 pub const MIN_PROPERTIES: &str = "minProperties";
160 pub const MAX_PROPERTIES: &str = "maxProperties";
161
162 pub const U64_CONSTRAINTS: &[&str] =
164 &[MIN_LENGTH, MAX_LENGTH, MIN_ITEMS, MAX_ITEMS, MIN_PROPERTIES, MAX_PROPERTIES];
165
166 pub const ADDITIONAL_PROPERTIES: &str = "additionalProperties";
169 pub const UNIQUE_ITEMS: &str = "uniqueItems";
170
171 pub const BOOL_CONSTRAINTS: &[&str] = &[ADDITIONAL_PROPERTIES, UNIQUE_ITEMS];
173
174 pub const PATTERN: &str = "pattern";
177 pub const FORMAT: &str = "format";
178 pub const REPLACEMENT_STRATEGY: &str = "replacementStrategy";
179 pub const DOCUMENTATION_URL: &str = "documentationUrl";
180 pub const SOURCE_URL: &str = "sourceUrl";
181
182 pub const STRING_CONSTRAINTS: &[&str] = &[PATTERN, FORMAT, REPLACEMENT_STRATEGY, DOCUMENTATION_URL, SOURCE_URL];
187
188 pub const TYPE: &str = "type";
191 pub const CONST: &str = "const";
192 pub const ITEMS: &str = "items";
193 pub const TYPE_NAME: &str = "typeName";
194
195 pub const UNREPRESENTED_VALIDATION_KEYWORDS: &[&str] =
202 &["propertyNames", "contains", "minContains", "maxContains", "additionalItems", "unevaluatedProperties"];
203
204 pub const MULTIPLE_OF: &str = "multipleOf";
207
208 pub const DEPENDENCIES: &str = "dependencies";
214
215 pub const RELATIONSHIP_REF: &str = "relationshipRef";
218
219 pub const REF_ANNOTATION_KEYWORDS: &[&str] = &[
225 "description",
226 "markdownDescription",
227 "title",
228 "examples",
229 "default",
230 "$comment",
231 "insertionOrder",
232 RELATIONSHIP_REF,
233 ];
234
235 pub const COMPILE_SUB_READS_ALL_PROP_FIELDS: bool = true;
246
247 pub const COMPILE_CONDITION_SCHEMA_FIELDS: &[&str] = &[PROPERTIES, REQUIRED, TYPE, ANY_OF, ONE_OF, NOT];
255}
256
257#[derive(Serialize, Deserialize)]
258pub struct CompiledSchema {
259 pub type_name: String,
260 #[serde(default)]
261 pub properties: BTreeMap<String, PropSchema>,
262 #[serde(default)]
263 pub definitions: BTreeMap<String, PropSchema>,
264 #[serde(default)]
265 pub required: Vec<String>,
266 #[serde(default)]
267 pub additional_properties: Option<bool>,
268 #[serde(default)]
269 pub read_only_properties: Vec<String>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub write_only_properties: Vec<String>,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub create_only_properties: Vec<String>,
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub deprecated_properties: Vec<String>,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub conditional_create_only_properties: Vec<String>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub primary_identifier: Vec<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub replacement_strategy: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub documentation_url: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub source_url: Option<String>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub all_of: Vec<SubSchema>,
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub any_of: Vec<SubSchema>,
290 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub one_of: Vec<SubSchema>,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub if_then_else: Vec<IfThenElse>,
294 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
295 pub dependent_required: BTreeMap<String, Vec<String>>,
296 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
297 pub dependent_excluded: BTreeMap<String, Vec<String>>,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub required_or: Vec<String>,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub required_xor: Vec<String>,
302}
303
304#[derive(Serialize, Deserialize, Default)]
305pub struct IfThenElse {
306 pub condition: ConditionSchema,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub then_schema: Option<SubSchema>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub else_schema: Option<SubSchema>,
311}
312
313#[derive(Serialize, Deserialize, Default)]
314pub struct ConditionSchema {
315 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
316 pub properties: BTreeMap<String, PropSchema>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub required: Vec<String>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
323 pub absent: Vec<String>,
324 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
329 pub prop_type: Option<PropType>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub any_of: Vec<ConditionSchema>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub one_of: Vec<ConditionSchema>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub not: Option<Box<ConditionSchema>>,
338}
339
340#[derive(Serialize, Deserialize, Default)]
341pub struct PropSchema {
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub ref_name: Option<String>,
344 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
345 pub prop_type: Option<PropType>,
346 #[serde(default, rename = "enum", skip_serializing_if = "Vec::is_empty")]
347 pub enum_values: Vec<serde_json::Value>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub enum_case_insensitive: Vec<serde_json::Value>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub not_enum: Vec<serde_json::Value>,
352 #[serde(default, rename = "const", skip_serializing_if = "Option::is_none")]
353 pub const_value: Option<serde_json::Value>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub pattern: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub minimum: Option<f64>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub maximum: Option<f64>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub exclusive_minimum: Option<f64>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub exclusive_maximum: Option<f64>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub multiple_of: Option<f64>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub min_length: Option<u64>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub max_length: Option<u64>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub min_items: Option<u64>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub max_items: Option<u64>,
374 #[serde(default, skip_serializing_if = "skip_unless_true")]
379 pub unique_items: Option<bool>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub min_properties: Option<u64>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub max_properties: Option<u64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub format: Option<String>,
386 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
387 pub properties: BTreeMap<String, PropSchema>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub required: Vec<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub additional_properties: Option<bool>,
392 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
393 pub pattern_properties: BTreeMap<String, PropSchema>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub items: Option<Box<PropSchema>>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub all_of: Vec<SubSchema>,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub any_of: Vec<SubSchema>,
400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub one_of: Vec<SubSchema>,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub if_then_else: Vec<IfThenElse>,
404 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
405 pub dependent_required: BTreeMap<String, Vec<String>>,
406 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
407 pub dependent_excluded: BTreeMap<String, Vec<String>>,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
409 pub required_or: Vec<String>,
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
411 pub required_xor: Vec<String>,
412}
413fn skip_unless_true(value: &Option<bool>) -> bool {
414 *value != Some(true)
415}
416
417pub type SubSchema = PropSchema;
421
422#[derive(Serialize, Deserialize)]
423#[serde(untagged)]
424pub enum PropType {
425 Single(String),
426 Multi(Vec<String>),
427}
428
429fn convert_property_paths(raw: &serde_json::Value) -> Vec<String> {
431 raw.as_array()
432 .map(|arr| {
433 arr.iter()
434 .filter_map(|v| {
435 v.as_str()
436 .and_then(|s| s.strip_prefix(keywords::PROPERTIES_PATH_PREFIX))
437 .map(|s| s.replace('/', "."))
438 })
439 .collect()
440 })
441 .unwrap_or_default()
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454pub enum RefSiblings {
455 Enforce,
457 Ignore,
459}
460
461pub fn compile_schema(type_name: &str, raw: &serde_json::Value) -> CompiledSchema {
465 compile_schema_with(type_name, raw, RefSiblings::Enforce)
466}
467
468pub fn compile_schema_with(type_name: &str, raw: &serde_json::Value, ref_siblings: RefSiblings) -> CompiledSchema {
478 let mut defs = BTreeMap::new();
479 if let Some(d) = raw.get(keywords::DEFINITIONS).and_then(|v| v.as_object()) {
480 for (k, v) in d {
481 defs.insert(k.clone(), compile_prop_with(v, ref_siblings));
482 }
483 }
484 let mut props = BTreeMap::new();
485 if let Some(p) = raw.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
486 for (k, v) in p {
487 props.insert(k.clone(), compile_prop_with(v, ref_siblings));
488 }
489 }
490
491 let mut all_of = Vec::new();
492 let mut if_then_else = Vec::new();
493 if let Some(arr) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
494 for item in arr {
495 if item.get(keywords::IF).is_some() {
496 if let Some(ite) = compile_if_then_else(item, ref_siblings) {
497 if_then_else.push(ite);
498 }
499 } else {
500 all_of.push(compile_sub(item, ref_siblings));
501 }
502 }
503 }
504
505 CompiledSchema {
506 type_name: type_name.to_string(),
507 properties: props,
508 definitions: defs,
509 required: str_arr(raw.get(keywords::REQUIRED)),
510 additional_properties: raw.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
511 read_only_properties: convert_property_paths(
512 raw.get(keywords::READ_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
513 ),
514 write_only_properties: convert_property_paths(
515 raw.get(keywords::WRITE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
516 ),
517 create_only_properties: convert_property_paths(
518 raw.get(keywords::CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
519 ),
520 deprecated_properties: convert_property_paths(
521 raw.get(keywords::DEPRECATED_PROPERTIES).unwrap_or(&serde_json::Value::Null),
522 ),
523 conditional_create_only_properties: convert_property_paths(
524 raw.get(keywords::CONDITIONAL_CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
525 ),
526 primary_identifier: convert_property_paths(
527 raw.get(keywords::PRIMARY_IDENTIFIER).unwrap_or(&serde_json::Value::Null),
528 ),
529 replacement_strategy: raw
530 .get(keywords::REPLACEMENT_STRATEGY)
531 .and_then(|v| v.as_str())
532 .filter(|s| !s.is_empty())
533 .map(String::from),
534 documentation_url: raw
535 .get(keywords::DOCUMENTATION_URL)
536 .and_then(|v| v.as_str())
537 .filter(|s| !s.is_empty())
538 .map(String::from),
539 source_url: raw.get(keywords::SOURCE_URL).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
540 all_of,
541 any_of: compile_subs(raw.get(keywords::ANY_OF), ref_siblings),
542 one_of: compile_subs(raw.get(keywords::ONE_OF), ref_siblings),
543 if_then_else,
544 dependent_required: compile_dependent_required(raw),
545 dependent_excluded: str_map(raw.get(keywords::DEPENDENT_EXCLUDED)),
546 required_or: str_arr(raw.get(keywords::REQUIRED_OR)),
547 required_xor: str_arr(raw.get(keywords::REQUIRED_XOR)),
548 }
549}
550
551fn compile_dependent_required(raw: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
554 let mut dep_req = str_map(raw.get(keywords::DEPENDENT_REQUIRED));
555 if let Some(deps_obj) = raw.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
556 for (trigger, value) in deps_obj {
557 if let Some(arr) = value.as_array() {
558 let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
559 if !names.is_empty() {
560 dep_req.entry(trigger.clone()).or_default().extend(names);
561 }
562 }
563 }
564 }
565 for deps in dep_req.values_mut() {
566 deps.sort();
567 deps.dedup();
568 }
569 dep_req
570}
571
572fn compile_if_then_else(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
573 let if_val = raw.get(keywords::IF)?;
574 let condition = compile_condition_schema(if_val, ref_siblings);
575 let then_schema = raw.get(keywords::THEN).map(|branch| compile_sub(branch, ref_siblings));
576 let else_schema = raw.get(keywords::ELSE).map(|branch| compile_sub(branch, ref_siblings));
577 if then_schema.is_none() && else_schema.is_none() {
578 return None;
579 }
580 Some(IfThenElse { condition, then_schema, else_schema })
581}
582
583fn compile_condition_schema(raw: &serde_json::Value, ref_siblings: RefSiblings) -> ConditionSchema {
584 let obj = match raw.as_object() {
585 Some(o) => o,
586 None => return ConditionSchema::default(),
587 };
588 let mut props = BTreeMap::new();
589 let mut absent = Vec::new();
590 if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
591 for (k, v) in p {
592 if v == &serde_json::Value::Bool(false) {
593 absent.push(k.clone());
594 } else {
595 props.insert(k.clone(), compile_prop_with(v, ref_siblings));
596 }
597 }
598 }
599 let compile_sub_conditions = |keyword: &str| -> Vec<ConditionSchema> {
600 obj.get(keyword)
601 .and_then(|v| v.as_array())
602 .map(|arr| arr.iter().map(|entry| compile_condition_schema(entry, ref_siblings)).collect())
603 .unwrap_or_default()
604 };
605 let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
606 ConditionSchema {
607 properties: props,
608 required: str_arr(obj.get(keywords::REQUIRED)),
609 absent,
610 prop_type,
611 any_of: compile_sub_conditions(keywords::ANY_OF),
612 one_of: compile_sub_conditions(keywords::ONE_OF),
613 not: obj.get(keywords::NOT).map(|negated| Box::new(compile_condition_schema(negated, ref_siblings))),
614 }
615}
616
617pub fn unsupported_root_conditionals(type_name: &str, raw: &serde_json::Value) -> Vec<String> {
625 let Some(all_of) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) else {
626 return Vec::new();
627 };
628 let mut findings = Vec::new();
629 for (index, entry) in all_of.iter().enumerate() {
630 if let Some(condition) = entry.get(keywords::IF) {
631 audit_condition(condition, &format!("{type_name} allOf[{index}].if"), &mut findings);
632 }
633 }
634 findings
635}
636
637fn audit_condition(condition: &serde_json::Value, location: &str, findings: &mut Vec<String>) {
638 let Some(members) = condition.as_object() else {
639 findings.push(format!("{location} must be a JSON object"));
640 return;
641 };
642 let unsupported: Vec<&str> = members
643 .keys()
644 .map(String::as_str)
645 .filter(|key| {
646 !keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(key) && !keywords::REF_ANNOTATION_KEYWORDS.contains(key)
647 })
648 .collect();
649 if !unsupported.is_empty() {
650 findings.push(format!("{location} uses keywords the condition compiler does not represent: {unsupported:?}"));
651 }
652 let states_a_condition =
653 members.keys().any(|key| keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(&key.as_str()));
654 if !states_a_condition {
655 findings.push(format!("{location} states no condition"));
656 }
657 for keyword in [keywords::ANY_OF, keywords::ONE_OF] {
658 if let Some(sub_conditions) = members.get(keyword).and_then(|v| v.as_array()) {
659 for (index, sub_condition) in sub_conditions.iter().enumerate() {
660 audit_condition(sub_condition, &format!("{location}.{keyword}[{index}]"), findings);
661 }
662 }
663 }
664 if let Some(negated) = members.get(keywords::NOT) {
665 audit_condition(negated, &format!("{location}.not"), findings);
666 }
667}
668
669fn compile_prop_type(raw: &serde_json::Value) -> PropType {
673 match raw {
674 serde_json::Value::String(s) => PropType::Single(s.clone()),
675 serde_json::Value::Array(a) => PropType::Multi(a.iter().filter_map(|v| v.as_str().map(String::from)).collect()),
676 _ => PropType::Single("string".into()),
677 }
678}
679
680fn compile_prop_with(raw: &serde_json::Value, ref_siblings: RefSiblings) -> PropSchema {
681 let obj = match raw.as_object() {
682 Some(o) => o,
683 None => return PropSchema::default(),
684 };
685 let raw_ref = obj.get(keywords::REF).and_then(|v| v.as_str());
692 let ref_name = raw_ref.and_then(|ref_str| ref_str.strip_prefix(keywords::DEFINITIONS_REF_PREFIX).map(String::from));
693 if raw_ref.is_some() && ref_siblings == RefSiblings::Ignore {
694 return PropSchema { ref_name, ..Default::default() };
695 }
696 if ref_name.is_some() {
698 let has_constraint_siblings = obj.keys().any(|key| {
699 key != keywords::REF
700 && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
701 && key != keywords::RELATIONSHIP_REF
702 });
703 if !has_constraint_siblings {
704 return PropSchema { ref_name, ..Default::default() };
705 }
706 }
707
708 let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
709 let mut sub_props = BTreeMap::new();
710 if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
711 for (k, v) in p {
712 sub_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
713 }
714 }
715 let mut pat_props = BTreeMap::new();
716 if let Some(p) = obj.get(keywords::PATTERN_PROPERTIES).and_then(|v| v.as_object()) {
717 for (k, v) in p {
718 pat_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
719 }
720 }
721 let items = obj.get(keywords::ITEMS).map(|v| Box::new(compile_prop_with(v, ref_siblings)));
722
723 let mut dep_req = str_map(obj.get(keywords::DEPENDENT_REQUIRED).cloned().as_ref());
725 if let Some(deps_obj) = obj.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
726 for (trigger, value) in deps_obj {
727 if let Some(arr) = value.as_array() {
728 let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
729 if !names.is_empty() {
730 dep_req.entry(trigger.clone()).or_default().extend(names);
731 }
732 }
733 }
736 }
737 for deps in dep_req.values_mut() {
739 deps.sort();
740 deps.dedup();
741 }
742
743 let mut if_then_else = Vec::new();
745 if obj.get(keywords::IF).is_some()
746 && let Some(ite) = compile_if_then_else(raw, ref_siblings)
747 {
748 if_then_else.push(ite);
749 }
750
751 let mut all_of_branches = Vec::new();
753 if let Some(arr) = obj.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
754 for item in arr {
755 if item.get(keywords::IF).is_some() {
756 if let Some(ite) = compile_if_then_else(item, ref_siblings) {
757 if_then_else.push(ite);
758 }
759 } else {
760 all_of_branches.push(compile_sub(item, ref_siblings));
761 }
762 }
763 }
764
765 PropSchema {
766 ref_name,
767 prop_type,
768 enum_values: obj.get(keywords::ENUM).and_then(|v| v.as_array()).cloned().unwrap_or_default(),
769 enum_case_insensitive: obj
770 .get(keywords::ENUM_CASE_INSENSITIVE)
771 .and_then(|v| v.as_array())
772 .cloned()
773 .unwrap_or_default(),
774 not_enum: obj
775 .get(keywords::NOT)
776 .and_then(|v| v.get(keywords::ENUM))
777 .and_then(|v| v.as_array())
778 .cloned()
779 .unwrap_or_default(),
780 const_value: obj.get(keywords::CONST).cloned(),
781 pattern: obj.get(keywords::PATTERN).and_then(|v| v.as_str()).map(String::from),
782 minimum: obj.get(keywords::MINIMUM).and_then(|v| v.as_f64()),
783 maximum: obj.get(keywords::MAXIMUM).and_then(|v| v.as_f64()),
784 exclusive_minimum: obj.get(keywords::EXCLUSIVE_MINIMUM).and_then(|v| v.as_f64()),
785 exclusive_maximum: obj.get(keywords::EXCLUSIVE_MAXIMUM).and_then(|v| v.as_f64()),
786 multiple_of: obj.get(keywords::MULTIPLE_OF).and_then(|v| v.as_f64()),
787 min_length: obj.get(keywords::MIN_LENGTH).and_then(|v| v.as_u64()),
788 max_length: obj.get(keywords::MAX_LENGTH).and_then(|v| v.as_u64()),
789 min_items: obj.get(keywords::MIN_ITEMS).and_then(|v| v.as_u64()),
790 max_items: obj.get(keywords::MAX_ITEMS).and_then(|v| v.as_u64()),
791 unique_items: obj.get(keywords::UNIQUE_ITEMS).and_then(|v| v.as_bool()),
792 min_properties: obj.get(keywords::MIN_PROPERTIES).and_then(|v| v.as_u64()),
793 max_properties: obj.get(keywords::MAX_PROPERTIES).and_then(|v| v.as_u64()),
794 format: obj.get(keywords::FORMAT).and_then(|v| v.as_str()).map(String::from),
795 properties: sub_props,
796 required: str_arr(obj.get(keywords::REQUIRED).cloned().as_ref()),
797 additional_properties: obj.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
798 pattern_properties: pat_props,
799 items,
800 all_of: all_of_branches,
801 any_of: compile_subs(obj.get(keywords::ANY_OF).cloned().as_ref(), ref_siblings),
802 one_of: compile_subs(obj.get(keywords::ONE_OF).cloned().as_ref(), ref_siblings),
803 if_then_else,
804 dependent_required: dep_req,
805 dependent_excluded: str_map(obj.get(keywords::DEPENDENT_EXCLUDED).cloned().as_ref()),
806 required_or: str_arr(obj.get(keywords::REQUIRED_OR).cloned().as_ref()),
807 required_xor: str_arr(obj.get(keywords::REQUIRED_XOR).cloned().as_ref()),
808 }
809}
810
811fn compile_sub(raw: &serde_json::Value, ref_siblings: RefSiblings) -> SubSchema {
812 compile_prop_with(raw, ref_siblings)
813}
814
815fn compile_subs(val: Option<&serde_json::Value>, ref_siblings: RefSiblings) -> Vec<SubSchema> {
816 let Some(arr) = val.and_then(|v| v.as_array()) else {
817 return Vec::new();
818 };
819 arr.iter().map(|entry| compile_sub(entry, ref_siblings)).collect()
820}
821
822fn str_arr(val: Option<&serde_json::Value>) -> Vec<String> {
823 val.and_then(|v| v.as_array())
824 .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
825 .unwrap_or_default()
826}
827
828fn str_map(val: Option<&serde_json::Value>) -> BTreeMap<String, Vec<String>> {
829 val.and_then(|v| v.as_object())
830 .map(|m| m.iter().map(|(k, v)| (k.clone(), str_arr(Some(v)))).collect())
831 .unwrap_or_default()
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use serde_json::json;
838
839 #[test]
840 fn compiles_properties_definitions_and_refs() {
841 let compiled = compile_schema(
842 "AWS::Test::T",
843 &json!({
844 "properties": {
845 "Name": { "type": "string", "pattern": "^a", "minLength": 1 },
846 "Cfg": { "$ref": "#/definitions/Config" },
847 "Kinds": { "type": ["string", "null"] }
848 },
849 "definitions": {
850 "Config": {
851 "type": "object",
852 "required": ["Inner"],
853 "requiredOr": ["First", "Second"],
854 "requiredXor": ["Left", "Right"]
855 }
856 },
857 "required": ["Name"],
858 "additionalProperties": false
859 }),
860 );
861 assert_eq!(compiled.type_name, "AWS::Test::T");
862 assert_eq!(compiled.required, vec!["Name".to_string()]);
863 assert_eq!(compiled.additional_properties, Some(false));
864 assert_eq!(compiled.properties["Cfg"].ref_name.as_deref(), Some("Config"));
865 assert_eq!(compiled.definitions["Config"].required, vec!["Inner".to_string()]);
866 assert_eq!(compiled.definitions["Config"].required_or, vec!["First".to_string(), "Second".to_string()]);
867 assert_eq!(compiled.definitions["Config"].required_xor, vec!["Left".to_string(), "Right".to_string()]);
868 assert_eq!(compiled.properties["Name"].pattern.as_deref(), Some("^a"));
869 assert_eq!(compiled.properties["Name"].min_length, Some(1));
870 match compiled.properties["Kinds"].prop_type.as_ref().expect("a multi type is compiled") {
871 PropType::Multi(names) => assert_eq!(names, &vec!["string".to_string(), "null".to_string()]),
872 PropType::Single(name) => panic!("expected a multi type, got {name}"),
873 }
874 }
875
876 #[test]
877 fn converts_property_pointer_paths_to_dot_notation() {
878 let compiled = compile_schema(
879 "AWS::Test::T",
880 &json!({
881 "readOnlyProperties": ["/properties/Arn", "/properties/Nested/Id"],
882 "writeOnlyProperties": ["/properties/Secret"],
883 "createOnlyProperties": ["/properties/Name"],
884 "deprecatedProperties": ["/properties/Old"],
885 "conditionalCreateOnlyProperties": ["/properties/Maybe"],
886 "primaryIdentifier": ["/properties/Name"]
887 }),
888 );
889 assert_eq!(compiled.read_only_properties, vec!["Arn".to_string(), "Nested.Id".to_string()]);
890 assert_eq!(compiled.write_only_properties, vec!["Secret".to_string()]);
891 assert_eq!(compiled.create_only_properties, vec!["Name".to_string()]);
892 assert_eq!(compiled.deprecated_properties, vec!["Old".to_string()]);
893 assert_eq!(compiled.conditional_create_only_properties, vec!["Maybe".to_string()]);
894 assert_eq!(compiled.primary_identifier, vec!["Name".to_string()]);
895 }
896
897 #[test]
898 fn splits_all_of_into_plain_and_conditional_entries() {
899 let compiled = compile_schema(
900 "AWS::Test::T",
901 &json!({
902 "allOf": [
903 { "required": ["Plain"] },
904 { "if": { "properties": { "A": { "enum": ["x"] } } }, "then": { "required": ["B"] } },
905 { "if": { "properties": { "A": { "enum": ["y"] } } } }
906 ]
907 }),
908 );
909 assert_eq!(compiled.all_of.len(), 1, "plain entries stay in all_of");
910 assert_eq!(compiled.all_of[0].required, vec!["Plain".to_string()]);
911 assert_eq!(compiled.if_then_else.len(), 1, "an if with neither then nor else is dropped");
912 assert_eq!(compiled.if_then_else[0].then_schema.as_ref().expect("then branch").required, vec!["B".to_string()]);
913 }
914
915 #[test]
916 fn preserves_unique_items_presence() {
917 let compiled = compile_schema(
918 "AWS::Test::T",
919 &json!({
920 "properties": {
921 "Strict": { "type": "array", "uniqueItems": true },
922 "Relaxed": { "type": "array", "uniqueItems": false },
923 "Silent": { "type": "array" }
924 }
925 }),
926 );
927 assert_eq!(compiled.properties["Strict"].unique_items, Some(true));
928 assert_eq!(
929 compiled.properties["Relaxed"].unique_items,
930 Some(false),
931 "an explicit false must be distinguishable from an omitted keyword"
932 );
933 assert_eq!(compiled.properties["Silent"].unique_items, None);
934 }
935
936 #[test]
937 fn unique_items_serialization_only_emits_true() {
938 let compiled = compile_schema(
941 "AWS::Test::T",
942 &json!({
943 "properties": {
944 "Strict": { "uniqueItems": true },
945 "Relaxed": { "uniqueItems": false },
946 "Silent": {}
947 }
948 }),
949 );
950 let json = serde_json::to_value(&compiled).expect("a compiled schema serializes");
951 let properties = json.get("properties").and_then(|v| v.as_object()).expect("properties are serialized");
952 assert_eq!(properties["Strict"].get("unique_items"), Some(&json!(true)));
953 assert!(properties["Relaxed"].get("unique_items").is_none(), "explicit false must be omitted");
954 assert!(properties["Silent"].get("unique_items").is_none(), "an absent keyword must be omitted");
955 }
956
957 #[test]
958 fn compiles_both_enum_representations_and_constraint_keywords() {
959 let compiled = compile_schema(
960 "AWS::Test::T",
961 &json!({
962 "properties": {
963 "Exact": { "enum": ["a", "b"] },
964 "Insensitive": { "enumCaseInsensitive": ["a", "b"] },
965 "Excluded": { "not": { "enum": ["bad"] } },
966 "Fixed": { "const": 7 }
967 },
968 "dependentRequired": { "A": ["B"] },
969 "dependentExcluded": { "C": ["D"] },
970 "requiredOr": ["A", "B"],
971 "requiredXor": ["C", "D"]
972 }),
973 );
974 assert_eq!(compiled.properties["Exact"].enum_values, vec![json!("a"), json!("b")]);
975 assert_eq!(compiled.properties["Insensitive"].enum_case_insensitive, vec![json!("a"), json!("b")]);
976 assert_eq!(compiled.properties["Excluded"].not_enum, vec![json!("bad")]);
977 assert_eq!(compiled.properties["Fixed"].const_value, Some(json!(7)));
978 assert_eq!(compiled.dependent_required.get("A"), Some(&vec!["B".to_string()]));
979 assert_eq!(compiled.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
980 assert_eq!(compiled.required_or, vec!["A".to_string(), "B".to_string()]);
981 assert_eq!(compiled.required_xor, vec!["C".to_string(), "D".to_string()]);
982 }
983
984 #[test]
985 fn compiles_nested_properties_items_and_pattern_properties() {
986 let compiled = compile_schema(
987 "AWS::Test::T",
988 &json!({
989 "properties": {
990 "Cfg": { "type": "object", "properties": { "Inner": { "type": "string" } } },
991 "Arr": { "type": "array", "items": { "type": "string", "maxLength": 3 } },
992 "Map": { "type": "object", "patternProperties": { "^k$": { "type": "integer" } } }
993 }
994 }),
995 );
996 assert!(compiled.properties["Cfg"].properties["Inner"].prop_type.is_some(), "nested type is compiled");
997 assert_eq!(compiled.properties["Arr"].items.as_ref().expect("items").max_length, Some(3));
998 assert!(compiled.properties["Map"].pattern_properties.contains_key("^k$"));
999 }
1000
1001 #[test]
1004 fn compile_sub_compiles_all_prop_schema_fields() {
1005 let sub = compile_sub(
1008 &json!({
1009 "required": ["A"],
1010 "properties": { "P": { "type": "string" } },
1011 "additionalProperties": false,
1012 "dependentRequired": { "A": ["B"] },
1013 "dependentExcluded": { "C": ["D"] },
1014 "type": "object",
1015 "enum": ["x"],
1016 "pattern": "^a",
1017 "minimum": 1.0,
1018 "multipleOf": 5.0,
1019 "minLength": 2,
1020 "anyOf": [{ "required": ["Z"] }]
1021 }),
1022 RefSiblings::Enforce,
1023 );
1024 assert_eq!(sub.required, vec!["A".to_string()]);
1025 assert!(sub.properties.contains_key("P"));
1026 assert_eq!(sub.additional_properties, Some(false));
1027 assert_eq!(sub.dependent_required.get("A"), Some(&vec!["B".to_string()]));
1028 assert_eq!(sub.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
1029 assert!(sub.prop_type.is_some());
1030 assert_eq!(sub.enum_values, vec![json!("x")]);
1031 assert_eq!(sub.pattern.as_deref(), Some("^a"));
1032 assert_eq!(sub.minimum, Some(1.0));
1033 assert_eq!(sub.multiple_of, Some(5.0));
1034 assert_eq!(sub.min_length, Some(2));
1035 assert_eq!(sub.any_of.len(), 1);
1036 }
1037
1038 #[test]
1039 fn compile_condition_schema_fields_matches_behavior() {
1040 let cond = compile_condition_schema(
1043 &json!({
1044 "properties": { "A": { "enum": ["x"] } },
1045 "required": ["A"],
1046 "type": "object",
1047 "anyOf": [{ "properties": { "B": { "enum": ["y"] } } }],
1048 "oneOf": [{ "required": ["C"] }, { "properties": { "C": false } }],
1049 "not": { "required": ["D"] }
1050 }),
1051 RefSiblings::Enforce,
1052 );
1053 assert!(cond.properties.contains_key("A"));
1054 assert_eq!(cond.required, vec!["A".to_string()]);
1055 assert_eq!(cond.any_of.len(), 1);
1056 assert_eq!(cond.one_of.len(), 2);
1057 assert_eq!(
1058 cond.one_of[1].absent,
1059 vec!["C".to_string()],
1060 "a `false` property schema means the property is absent"
1061 );
1062 assert_eq!(cond.not.as_ref().map(|negated| negated.required.clone()), Some(vec!["D".to_string()]));
1063 assert!(cond.prop_type.is_some(), "the condition's type must be compiled");
1064 assert_eq!(
1065 keywords::COMPILE_CONDITION_SCHEMA_FIELDS,
1066 &[
1067 keywords::PROPERTIES,
1068 keywords::REQUIRED,
1069 keywords::TYPE,
1070 keywords::ANY_OF,
1071 keywords::ONE_OF,
1072 keywords::NOT
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn negated_and_exclusive_conditions_survive_compilation() {
1079 let compiled = compile_schema(
1080 "AWS::Test::Domain",
1081 &json!({
1082 "properties": { "Domain": { "type": "string" }, "CustomDomainConfig": { "type": "object" } },
1083 "allOf": [
1084 {
1085 "if": { "not": { "required": ["CustomDomainConfig"] } },
1086 "then": { "properties": { "Domain": { "pattern": "^[a-z0-9-]+$" } } }
1087 },
1088 {
1089 "if": { "oneOf": [
1090 { "properties": { "Type": { "const": "application" } }, "required": ["Type"] },
1091 { "properties": { "Type": false } }
1092 ] },
1093 "then": { "properties": { "Subnets": { "minItems": 2 } } }
1094 }
1095 ]
1096 }),
1097 );
1098 let negated = compiled.if_then_else[0].condition.not.as_ref().expect("`not` is compiled");
1099 assert_eq!(negated.required, vec!["CustomDomainConfig".to_string()]);
1100 let exclusive = &compiled.if_then_else[1].condition.one_of;
1101 assert_eq!(exclusive.len(), 2);
1102 assert!(exclusive[0].properties.contains_key("Type"));
1103 assert_eq!(exclusive[1].absent, vec!["Type".to_string()]);
1104 }
1105
1106 #[test]
1107 fn root_conditional_audit_accepts_the_supported_grammar() {
1108 let schema = json!({
1109 "allOf": [
1110 { "if": { "required": ["A"] }, "then": {} },
1111 { "if": { "not": { "required": ["B"] } }, "then": {} },
1112 { "if": { "oneOf": [{ "properties": { "T": { "const": "x" } }, "required": ["T"] }, { "properties": { "T": false } }] }, "then": {} },
1113 { "if": { "anyOf": [{ "required": ["C"] }], "description": "annotation" }, "then": {} },
1114 { "properties": { "Plain": { "type": "string" } } }
1115 ]
1116 });
1117 assert!(unsupported_root_conditionals("AWS::Test::T", &schema).is_empty());
1118 }
1119
1120 #[test]
1121 fn root_conditional_audit_rejects_unrepresented_keywords_and_empty_conditions() {
1122 let schema = json!({
1123 "allOf": [
1124 { "if": { "allOf": [{ "required": ["A"] }] }, "then": {} },
1125 { "if": { "description": "says nothing" }, "then": {} },
1126 { "if": { "oneOf": [{ "maximum": 60 }] }, "then": {} }
1127 ]
1128 });
1129 let findings = unsupported_root_conditionals("AWS::Test::T", &schema);
1130 assert_eq!(findings.len(), 5, "{findings:?}");
1131 assert!(findings[0].contains("allOf[0].if") && findings[0].contains("\"allOf\""));
1132 assert!(findings[1].contains("allOf[0].if states no condition"));
1133 assert!(findings[2].contains("allOf[1].if states no condition"));
1134 assert!(findings[3].contains("allOf[2].if.oneOf[0]") && findings[3].contains("\"maximum\""));
1135 assert!(findings[4].contains("allOf[2].if.oneOf[0] states no condition"));
1136 }
1137
1138 #[test]
1139 fn numeric_constraints_compile_into_option_f64_fields() {
1140 let compiled = compile_schema(
1141 "AWS::Test::T",
1142 &json!({
1143 "properties": {
1144 "P": {
1145 "type": "number",
1146 "minimum": 1.5,
1147 "maximum": 99.9,
1148 "exclusiveMinimum": 0.1,
1149 "exclusiveMaximum": 100.0
1150 }
1151 }
1152 }),
1153 );
1154 let p = &compiled.properties["P"];
1155 assert_eq!(p.minimum, Some(1.5));
1156 assert_eq!(p.maximum, Some(99.9));
1157 assert_eq!(p.exclusive_minimum, Some(0.1));
1158 assert_eq!(p.exclusive_maximum, Some(100.0));
1159 }
1160
1161 #[test]
1162 fn u64_constraints_compile_into_option_u64_fields() {
1163 let compiled = compile_schema(
1164 "AWS::Test::T",
1165 &json!({
1166 "properties": {
1167 "P": {
1168 "type": "string",
1169 "minLength": 1,
1170 "maxLength": 256
1171 },
1172 "A": {
1173 "type": "array",
1174 "minItems": 0,
1175 "maxItems": 10
1176 },
1177 "O": {
1178 "type": "object",
1179 "minProperties": 1,
1180 "maxProperties": 5
1181 }
1182 }
1183 }),
1184 );
1185 assert_eq!(compiled.properties["P"].min_length, Some(1));
1186 assert_eq!(compiled.properties["P"].max_length, Some(256));
1187 assert_eq!(compiled.properties["A"].min_items, Some(0));
1188 assert_eq!(compiled.properties["A"].max_items, Some(10));
1189 assert_eq!(compiled.properties["O"].min_properties, Some(1));
1190 assert_eq!(compiled.properties["O"].max_properties, Some(5));
1191 }
1192
1193 #[test]
1194 fn string_constraints_compile_into_option_string_fields() {
1195 let compiled = compile_schema(
1196 "AWS::Test::T",
1197 &json!({
1198 "description": "resource documentation",
1199 "properties": {
1200 "P": { "type": "string", "pattern": "^x$", "format": "uri", "description": "desc" }
1201 },
1202 "replacementStrategy": "delete_then_create",
1203 "documentationUrl": "https://docs",
1204 "sourceUrl": "https://src"
1205 }),
1206 );
1207 let p = &compiled.properties["P"];
1208 assert_eq!(p.pattern.as_deref(), Some("^x$"));
1209 assert_eq!(p.format.as_deref(), Some("uri"));
1210 assert_eq!(compiled.replacement_strategy.as_deref(), Some("delete_then_create"));
1211 assert_eq!(compiled.documentation_url.as_deref(), Some("https://docs"));
1212 assert_eq!(compiled.source_url.as_deref(), Some("https://src"));
1213 let serialized = serde_json::to_string(&compiled).expect("compiled schema serializes");
1216 assert!(!serialized.contains("description"), "description must not reach the compiled schema: {serialized}");
1217 }
1218
1219 #[test]
1220 fn metadata_pointer_arrays_all_strip_properties_prefix() {
1221 let compiled = compile_schema(
1224 "AWS::Test::T",
1225 &json!({
1226 "readOnlyProperties": ["/properties/A"],
1227 "writeOnlyProperties": ["/properties/B"],
1228 "createOnlyProperties": ["/properties/C"],
1229 "deprecatedProperties": ["/properties/D"],
1230 "conditionalCreateOnlyProperties": ["/properties/E"],
1231 "primaryIdentifier": ["/properties/F"]
1232 }),
1233 );
1234 assert_eq!(compiled.read_only_properties, vec!["A"]);
1235 assert_eq!(compiled.write_only_properties, vec!["B"]);
1236 assert_eq!(compiled.create_only_properties, vec!["C"]);
1237 assert_eq!(compiled.deprecated_properties, vec!["D"]);
1238 assert_eq!(compiled.conditional_create_only_properties, vec!["E"]);
1239 assert_eq!(compiled.primary_identifier, vec!["F"]);
1240 }
1241
1242 #[test]
1243 fn definitions_ref_prefix_is_used_in_compile_prop() {
1244 let compiled = compile_schema(
1245 "AWS::Test::T",
1246 &json!({
1247 "properties": { "P": { "$ref": "#/definitions/D" } },
1248 "definitions": { "D": { "type": "object" } }
1249 }),
1250 );
1251 assert_eq!(compiled.properties["P"].ref_name.as_deref(), Some("D"));
1252 }
1253
1254 #[test]
1255 fn schema_maps_slice_matches_recursive_compilation() {
1256 let compiled = compile_schema(
1258 "AWS::Test::T",
1259 &json!({
1260 "properties": { "P": { "type": "string" } },
1261 "definitions": { "D": { "type": "object" } }
1262 }),
1263 );
1264 assert!(compiled.properties.contains_key("P"));
1265 assert!(compiled.definitions.contains_key("D"));
1266 let compiled_prop = compile_schema(
1268 "AWS::Test::T",
1269 &json!({
1270 "properties": {
1271 "Map": { "type": "object", "patternProperties": { "^k$": { "type": "string" } } }
1272 }
1273 }),
1274 );
1275 assert!(compiled_prop.properties["Map"].pattern_properties.contains_key("^k$"));
1276 }
1277
1278 #[test]
1279 fn ref_siblings_enforce_keeps_constraints_beside_a_ref() {
1280 let compiled = compile_schema_with(
1281 "AWS::Test::T",
1282 &json!({
1283 "properties": { "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" } },
1284 "definitions": { "D": { "type": "string" } }
1285 }),
1286 RefSiblings::Enforce,
1287 );
1288 let prop = &compiled.properties["P"];
1289 assert_eq!(prop.ref_name.as_deref(), Some("D"));
1290 assert_eq!(prop.max_length, Some(3), "Enforce must keep constraint siblings");
1291 assert_eq!(prop.pattern.as_deref(), Some("^a"));
1292 }
1293
1294 #[test]
1295 fn ref_siblings_ignore_drops_everything_beside_a_ref() {
1296 let compiled = compile_schema_with(
1301 "AWS::Test::T",
1302 &json!({
1303 "properties": {
1304 "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" },
1305 "Q": { "$ref": "not-a-definitions-ref", "maxLength": 3 }
1306 },
1307 "definitions": { "D": { "type": "string" } }
1308 }),
1309 RefSiblings::Ignore,
1310 );
1311 let prop = &compiled.properties["P"];
1312 assert_eq!(prop.ref_name.as_deref(), Some("D"));
1313 assert_eq!(prop.max_length, None, "Ignore must drop constraint siblings");
1314 assert_eq!(prop.pattern, None);
1315 let unresolvable = &compiled.properties["Q"];
1316 assert_eq!(unresolvable.ref_name, None);
1317 assert_eq!(
1318 unresolvable.max_length, None,
1319 "a $ref outside definitions compiles to an unconstrained property under Ignore, matching the \
1320 historical bundled artifact"
1321 );
1322 }
1323
1324 #[test]
1325 fn condition_schema_type_is_compiled() {
1326 let cond = compile_condition_schema(&json!({ "type": "object", "required": ["A"] }), RefSiblings::Enforce);
1327 match cond.prop_type.as_ref().expect("the condition's type must be compiled") {
1328 PropType::Single(name) => assert_eq!(name, "object"),
1329 PropType::Multi(names) => panic!("expected a single type, got {names:?}"),
1330 }
1331 }
1332}