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, ALL_OF, 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 all_of: Vec<ConditionSchema>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub any_of: Vec<ConditionSchema>,
334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub one_of: Vec<ConditionSchema>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub not: Option<Box<ConditionSchema>>,
340}
341
342#[derive(Serialize, Deserialize, Default)]
343pub struct PropSchema {
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub ref_name: Option<String>,
346 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
347 pub prop_type: Option<PropType>,
348 #[serde(default, rename = "enum", skip_serializing_if = "Vec::is_empty")]
349 pub enum_values: Vec<serde_json::Value>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub enum_case_insensitive: Vec<serde_json::Value>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub not_enum: Vec<serde_json::Value>,
354 #[serde(default, rename = "const", skip_serializing_if = "Option::is_none")]
355 pub const_value: Option<serde_json::Value>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub pattern: Option<String>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub minimum: Option<f64>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub maximum: Option<f64>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub exclusive_minimum: Option<f64>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub exclusive_maximum: Option<f64>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub multiple_of: Option<f64>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub min_length: Option<u64>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub max_length: Option<u64>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub min_items: Option<u64>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub max_items: Option<u64>,
376 #[serde(default, skip_serializing_if = "skip_unless_true")]
381 pub unique_items: Option<bool>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub min_properties: Option<u64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub max_properties: Option<u64>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub format: Option<String>,
388 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
389 pub properties: BTreeMap<String, PropSchema>,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub required: Vec<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub additional_properties: Option<bool>,
394 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
395 pub pattern_properties: BTreeMap<String, PropSchema>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub items: Option<Box<PropSchema>>,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub all_of: Vec<SubSchema>,
400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub any_of: Vec<SubSchema>,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub one_of: Vec<SubSchema>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub if_then_else: Vec<IfThenElse>,
406 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
407 pub dependent_required: BTreeMap<String, Vec<String>>,
408 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub dependent_excluded: BTreeMap<String, Vec<String>>,
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
411 pub required_or: Vec<String>,
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub required_xor: Vec<String>,
414}
415fn skip_unless_true(value: &Option<bool>) -> bool {
416 *value != Some(true)
417}
418
419pub type SubSchema = PropSchema;
423
424#[derive(Serialize, Deserialize)]
425#[serde(untagged)]
426pub enum PropType {
427 Single(String),
428 Multi(Vec<String>),
429}
430
431fn convert_property_paths(raw: &serde_json::Value) -> Vec<String> {
433 raw.as_array()
434 .map(|arr| {
435 arr.iter()
436 .filter_map(|v| {
437 v.as_str()
438 .and_then(|s| s.strip_prefix(keywords::PROPERTIES_PATH_PREFIX))
439 .map(|s| s.replace('/', "."))
440 })
441 .collect()
442 })
443 .unwrap_or_default()
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum RefSiblings {
457 Enforce,
459 Ignore,
461}
462
463pub fn compile_schema(type_name: &str, raw: &serde_json::Value) -> CompiledSchema {
467 compile_schema_with(type_name, raw, RefSiblings::Enforce)
468}
469
470pub fn compile_schema_with(type_name: &str, raw: &serde_json::Value, ref_siblings: RefSiblings) -> CompiledSchema {
480 let mut defs = BTreeMap::new();
481 if let Some(d) = raw.get(keywords::DEFINITIONS).and_then(|v| v.as_object()) {
482 for (k, v) in d {
483 defs.insert(k.clone(), compile_prop_with(v, ref_siblings));
484 }
485 }
486 let mut props = BTreeMap::new();
487 if let Some(p) = raw.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
488 for (k, v) in p {
489 props.insert(k.clone(), compile_prop_with(v, ref_siblings));
490 }
491 }
492
493 let mut all_of = Vec::new();
494 let mut if_then_else = Vec::new();
495 if let Some(arr) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
496 for item in arr {
497 if item.get(keywords::IF).is_some() {
498 if let Some(ite) = compile_if_then_else(item, ref_siblings) {
499 if_then_else.push(ite);
500 }
501 } else if let Some(exclusion) = compile_negated_property_exclusion(item, ref_siblings) {
502 if_then_else.push(exclusion);
503 } else {
504 all_of.push(compile_sub(item, ref_siblings));
505 }
506 }
507 }
508
509 CompiledSchema {
510 type_name: type_name.to_string(),
511 properties: props,
512 definitions: defs,
513 required: str_arr(raw.get(keywords::REQUIRED)),
514 additional_properties: raw.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
515 read_only_properties: convert_property_paths(
516 raw.get(keywords::READ_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
517 ),
518 write_only_properties: convert_property_paths(
519 raw.get(keywords::WRITE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
520 ),
521 create_only_properties: convert_property_paths(
522 raw.get(keywords::CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
523 ),
524 deprecated_properties: convert_property_paths(
525 raw.get(keywords::DEPRECATED_PROPERTIES).unwrap_or(&serde_json::Value::Null),
526 ),
527 conditional_create_only_properties: convert_property_paths(
528 raw.get(keywords::CONDITIONAL_CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
529 ),
530 primary_identifier: convert_property_paths(
531 raw.get(keywords::PRIMARY_IDENTIFIER).unwrap_or(&serde_json::Value::Null),
532 ),
533 replacement_strategy: raw
534 .get(keywords::REPLACEMENT_STRATEGY)
535 .and_then(|v| v.as_str())
536 .filter(|s| !s.is_empty())
537 .map(String::from),
538 documentation_url: raw
539 .get(keywords::DOCUMENTATION_URL)
540 .and_then(|v| v.as_str())
541 .filter(|s| !s.is_empty())
542 .map(String::from),
543 source_url: raw.get(keywords::SOURCE_URL).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
544 all_of,
545 any_of: compile_subs(raw.get(keywords::ANY_OF), ref_siblings),
546 one_of: compile_subs(raw.get(keywords::ONE_OF), ref_siblings),
547 if_then_else,
548 dependent_required: compile_dependent_required(raw),
549 dependent_excluded: str_map(raw.get(keywords::DEPENDENT_EXCLUDED)),
550 required_or: str_arr(raw.get(keywords::REQUIRED_OR)),
551 required_xor: str_arr(raw.get(keywords::REQUIRED_XOR)),
552 }
553}
554
555fn compile_dependent_required(raw: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
558 let mut dep_req = str_map(raw.get(keywords::DEPENDENT_REQUIRED));
559 if let Some(deps_obj) = raw.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
560 for (trigger, value) in deps_obj {
561 if let Some(arr) = value.as_array() {
562 let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
563 if !names.is_empty() {
564 dep_req.entry(trigger.clone()).or_default().extend(names);
565 }
566 }
567 }
568 }
569 for deps in dep_req.values_mut() {
570 deps.sort();
571 deps.dedup();
572 }
573 dep_req
574}
575
576fn compile_negated_property_exclusion(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
577 let branch = raw.as_object()?;
578 if branch.keys().any(|key| key != keywords::NOT && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())) {
579 return None;
580 }
581
582 let negated_schema = branch.get(keywords::NOT)?;
583 let negated_members = negated_schema.as_object()?;
584 if negated_members.keys().any(|key| {
585 !matches!(key.as_str(), keywords::PROPERTIES | keywords::REQUIRED | keywords::TYPE)
586 && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
587 }) {
588 return None;
589 }
590
591 let excluded_properties = str_arr(negated_members.get(keywords::REQUIRED));
592 if excluded_properties.len() != 2 {
593 return None;
594 }
595 if let Some(property_constraints) = negated_members.get(keywords::PROPERTIES).and_then(|value| value.as_object())
596 && property_constraints.keys().any(|property| !excluded_properties.contains(property))
597 {
598 return None;
599 }
600
601 let mut dependent_excluded = BTreeMap::new();
602 dependent_excluded.insert(excluded_properties[0].clone(), vec![excluded_properties[1].clone()]);
603 dependent_excluded.insert(excluded_properties[1].clone(), vec![excluded_properties[0].clone()]);
604
605 Some(IfThenElse {
606 condition: compile_condition_schema(negated_schema, ref_siblings),
607 then_schema: Some(SubSchema { dependent_excluded, ..Default::default() }),
608 else_schema: None,
609 })
610}
611
612fn compile_if_then_else(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
613 let if_val = raw.get(keywords::IF)?;
614 let condition = compile_condition_schema(if_val, ref_siblings);
615 let then_schema = raw.get(keywords::THEN).map(|branch| compile_sub(branch, ref_siblings));
616 let else_schema = raw.get(keywords::ELSE).map(|branch| compile_sub(branch, ref_siblings));
617 if then_schema.is_none() && else_schema.is_none() {
618 return None;
619 }
620 Some(IfThenElse { condition, then_schema, else_schema })
621}
622
623pub fn compile_condition_schema(raw: &serde_json::Value, ref_siblings: RefSiblings) -> ConditionSchema {
628 let obj = match raw.as_object() {
629 Some(o) => o,
630 None => return ConditionSchema::default(),
631 };
632 let mut props = BTreeMap::new();
633 let mut absent = Vec::new();
634 if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
635 for (k, v) in p {
636 if v == &serde_json::Value::Bool(false) {
637 absent.push(k.clone());
638 } else {
639 props.insert(k.clone(), compile_prop_with(v, ref_siblings));
640 }
641 }
642 }
643 let compile_sub_conditions = |keyword: &str| -> Vec<ConditionSchema> {
644 obj.get(keyword)
645 .and_then(|v| v.as_array())
646 .map(|arr| arr.iter().map(|entry| compile_condition_schema(entry, ref_siblings)).collect())
647 .unwrap_or_default()
648 };
649 let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
650 ConditionSchema {
651 properties: props,
652 required: str_arr(obj.get(keywords::REQUIRED)),
653 absent,
654 prop_type,
655 all_of: compile_sub_conditions(keywords::ALL_OF),
656 any_of: compile_sub_conditions(keywords::ANY_OF),
657 one_of: compile_sub_conditions(keywords::ONE_OF),
658 not: obj.get(keywords::NOT).map(|negated| Box::new(compile_condition_schema(negated, ref_siblings))),
659 }
660}
661
662pub fn unsupported_root_conditionals(type_name: &str, raw: &serde_json::Value) -> Vec<String> {
670 let Some(all_of) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) else {
671 return Vec::new();
672 };
673 let mut findings = Vec::new();
674 for (index, entry) in all_of.iter().enumerate() {
675 if let Some(condition) = entry.get(keywords::IF) {
676 audit_condition(condition, &format!("{type_name} allOf[{index}].if"), &mut findings);
677 }
678 }
679 findings
680}
681
682fn audit_condition(condition: &serde_json::Value, location: &str, findings: &mut Vec<String>) {
683 let Some(members) = condition.as_object() else {
684 findings.push(format!("{location} must be a JSON object"));
685 return;
686 };
687 let unsupported: Vec<&str> = members
688 .keys()
689 .map(String::as_str)
690 .filter(|key| {
691 !keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(key) && !keywords::REF_ANNOTATION_KEYWORDS.contains(key)
692 })
693 .collect();
694 if !unsupported.is_empty() {
695 findings.push(format!("{location} uses keywords the condition compiler does not represent: {unsupported:?}"));
696 }
697 let states_a_condition =
698 members.keys().any(|key| keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(&key.as_str()));
699 if !states_a_condition {
700 findings.push(format!("{location} states no condition"));
701 }
702 for keyword in [keywords::ALL_OF, keywords::ANY_OF, keywords::ONE_OF] {
703 if let Some(sub_conditions) = members.get(keyword).and_then(|v| v.as_array()) {
704 for (index, sub_condition) in sub_conditions.iter().enumerate() {
705 audit_condition(sub_condition, &format!("{location}.{keyword}[{index}]"), findings);
706 }
707 }
708 }
709 if let Some(negated) = members.get(keywords::NOT) {
710 audit_condition(negated, &format!("{location}.not"), findings);
711 }
712}
713
714fn compile_prop_type(raw: &serde_json::Value) -> PropType {
718 match raw {
719 serde_json::Value::String(s) => PropType::Single(s.clone()),
720 serde_json::Value::Array(a) => PropType::Multi(a.iter().filter_map(|v| v.as_str().map(String::from)).collect()),
721 _ => PropType::Single("string".into()),
722 }
723}
724
725fn compile_prop_with(raw: &serde_json::Value, ref_siblings: RefSiblings) -> PropSchema {
726 let obj = match raw.as_object() {
727 Some(o) => o,
728 None => return PropSchema::default(),
729 };
730 let raw_ref = obj.get(keywords::REF).and_then(|v| v.as_str());
737 let ref_name = raw_ref.and_then(|ref_str| ref_str.strip_prefix(keywords::DEFINITIONS_REF_PREFIX).map(String::from));
738 if raw_ref.is_some() && ref_siblings == RefSiblings::Ignore {
739 return PropSchema { ref_name, ..Default::default() };
740 }
741 if ref_name.is_some() {
743 let has_constraint_siblings = obj.keys().any(|key| {
744 key != keywords::REF
745 && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
746 && key != keywords::RELATIONSHIP_REF
747 });
748 if !has_constraint_siblings {
749 return PropSchema { ref_name, ..Default::default() };
750 }
751 }
752
753 let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
754 let mut sub_props = BTreeMap::new();
755 if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
756 for (k, v) in p {
757 sub_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
758 }
759 }
760 let mut pat_props = BTreeMap::new();
761 if let Some(p) = obj.get(keywords::PATTERN_PROPERTIES).and_then(|v| v.as_object()) {
762 for (k, v) in p {
763 pat_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
764 }
765 }
766 let items = obj.get(keywords::ITEMS).map(|v| Box::new(compile_prop_with(v, ref_siblings)));
767
768 let mut dep_req = str_map(obj.get(keywords::DEPENDENT_REQUIRED).cloned().as_ref());
770 if let Some(deps_obj) = obj.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
771 for (trigger, value) in deps_obj {
772 if let Some(arr) = value.as_array() {
773 let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
774 if !names.is_empty() {
775 dep_req.entry(trigger.clone()).or_default().extend(names);
776 }
777 }
778 }
781 }
782 for deps in dep_req.values_mut() {
784 deps.sort();
785 deps.dedup();
786 }
787
788 let mut if_then_else = Vec::new();
790 if obj.get(keywords::IF).is_some()
791 && let Some(ite) = compile_if_then_else(raw, ref_siblings)
792 {
793 if_then_else.push(ite);
794 }
795
796 let mut all_of_branches = Vec::new();
798 if let Some(arr) = obj.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
799 for item in arr {
800 if item.get(keywords::IF).is_some() {
801 if let Some(ite) = compile_if_then_else(item, ref_siblings) {
802 if_then_else.push(ite);
803 }
804 } else {
805 all_of_branches.push(compile_sub(item, ref_siblings));
806 }
807 }
808 }
809
810 let not_enum = obj
811 .get(keywords::NOT)
812 .and_then(|value| value.as_object())
813 .map(|negated| {
814 negated
815 .get(keywords::ENUM)
816 .and_then(|value| value.as_array())
817 .cloned()
818 .or_else(|| negated.get(keywords::CONST).cloned().map(|value| vec![value]))
819 .unwrap_or_default()
820 })
821 .unwrap_or_default();
822
823 PropSchema {
824 ref_name,
825 prop_type,
826 enum_values: obj.get(keywords::ENUM).and_then(|v| v.as_array()).cloned().unwrap_or_default(),
827 enum_case_insensitive: obj
828 .get(keywords::ENUM_CASE_INSENSITIVE)
829 .and_then(|v| v.as_array())
830 .cloned()
831 .unwrap_or_default(),
832 not_enum,
833 const_value: obj.get(keywords::CONST).cloned(),
834 pattern: obj.get(keywords::PATTERN).and_then(|v| v.as_str()).map(String::from),
835 minimum: obj.get(keywords::MINIMUM).and_then(|v| v.as_f64()),
836 maximum: obj.get(keywords::MAXIMUM).and_then(|v| v.as_f64()),
837 exclusive_minimum: obj.get(keywords::EXCLUSIVE_MINIMUM).and_then(|v| v.as_f64()),
838 exclusive_maximum: obj.get(keywords::EXCLUSIVE_MAXIMUM).and_then(|v| v.as_f64()),
839 multiple_of: obj.get(keywords::MULTIPLE_OF).and_then(|v| v.as_f64()),
840 min_length: obj.get(keywords::MIN_LENGTH).and_then(|v| v.as_u64()),
841 max_length: obj.get(keywords::MAX_LENGTH).and_then(|v| v.as_u64()),
842 min_items: obj.get(keywords::MIN_ITEMS).and_then(|v| v.as_u64()),
843 max_items: obj.get(keywords::MAX_ITEMS).and_then(|v| v.as_u64()),
844 unique_items: obj.get(keywords::UNIQUE_ITEMS).and_then(|v| v.as_bool()),
845 min_properties: obj.get(keywords::MIN_PROPERTIES).and_then(|v| v.as_u64()),
846 max_properties: obj.get(keywords::MAX_PROPERTIES).and_then(|v| v.as_u64()),
847 format: obj.get(keywords::FORMAT).and_then(|v| v.as_str()).map(String::from),
848 properties: sub_props,
849 required: str_arr(obj.get(keywords::REQUIRED).cloned().as_ref()),
850 additional_properties: obj.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
851 pattern_properties: pat_props,
852 items,
853 all_of: all_of_branches,
854 any_of: compile_subs(obj.get(keywords::ANY_OF).cloned().as_ref(), ref_siblings),
855 one_of: compile_subs(obj.get(keywords::ONE_OF).cloned().as_ref(), ref_siblings),
856 if_then_else,
857 dependent_required: dep_req,
858 dependent_excluded: str_map(obj.get(keywords::DEPENDENT_EXCLUDED).cloned().as_ref()),
859 required_or: str_arr(obj.get(keywords::REQUIRED_OR).cloned().as_ref()),
860 required_xor: str_arr(obj.get(keywords::REQUIRED_XOR).cloned().as_ref()),
861 }
862}
863
864fn compile_sub(raw: &serde_json::Value, ref_siblings: RefSiblings) -> SubSchema {
865 compile_prop_with(raw, ref_siblings)
866}
867
868fn compile_subs(val: Option<&serde_json::Value>, ref_siblings: RefSiblings) -> Vec<SubSchema> {
869 let Some(arr) = val.and_then(|v| v.as_array()) else {
870 return Vec::new();
871 };
872 arr.iter().map(|entry| compile_sub(entry, ref_siblings)).collect()
873}
874
875fn str_arr(val: Option<&serde_json::Value>) -> Vec<String> {
876 val.and_then(|v| v.as_array())
877 .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
878 .unwrap_or_default()
879}
880
881fn str_map(val: Option<&serde_json::Value>) -> BTreeMap<String, Vec<String>> {
882 val.and_then(|v| v.as_object())
883 .map(|m| m.iter().map(|(k, v)| (k.clone(), str_arr(Some(v)))).collect())
884 .unwrap_or_default()
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use serde_json::json;
891
892 #[test]
893 fn compiles_properties_definitions_and_refs() {
894 let compiled = compile_schema(
895 "AWS::Test::T",
896 &json!({
897 "properties": {
898 "Name": { "type": "string", "pattern": "^a", "minLength": 1 },
899 "Cfg": { "$ref": "#/definitions/Config" },
900 "Kinds": { "type": ["string", "null"] }
901 },
902 "definitions": {
903 "Config": {
904 "type": "object",
905 "required": ["Inner"],
906 "requiredOr": ["First", "Second"],
907 "requiredXor": ["Left", "Right"]
908 }
909 },
910 "required": ["Name"],
911 "additionalProperties": false
912 }),
913 );
914 assert_eq!(compiled.type_name, "AWS::Test::T");
915 assert_eq!(compiled.required, vec!["Name".to_string()]);
916 assert_eq!(compiled.additional_properties, Some(false));
917 assert_eq!(compiled.properties["Cfg"].ref_name.as_deref(), Some("Config"));
918 assert_eq!(compiled.definitions["Config"].required, vec!["Inner".to_string()]);
919 assert_eq!(compiled.definitions["Config"].required_or, vec!["First".to_string(), "Second".to_string()]);
920 assert_eq!(compiled.definitions["Config"].required_xor, vec!["Left".to_string(), "Right".to_string()]);
921 assert_eq!(compiled.properties["Name"].pattern.as_deref(), Some("^a"));
922 assert_eq!(compiled.properties["Name"].min_length, Some(1));
923 match compiled.properties["Kinds"].prop_type.as_ref().expect("a multi type is compiled") {
924 PropType::Multi(names) => assert_eq!(names, &vec!["string".to_string(), "null".to_string()]),
925 PropType::Single(name) => panic!("expected a multi type, got {name}"),
926 }
927 }
928
929 #[test]
930 fn converts_property_pointer_paths_to_dot_notation() {
931 let compiled = compile_schema(
932 "AWS::Test::T",
933 &json!({
934 "readOnlyProperties": ["/properties/Arn", "/properties/Nested/Id"],
935 "writeOnlyProperties": ["/properties/Secret"],
936 "createOnlyProperties": ["/properties/Name"],
937 "deprecatedProperties": ["/properties/Old"],
938 "conditionalCreateOnlyProperties": ["/properties/Maybe"],
939 "primaryIdentifier": ["/properties/Name"]
940 }),
941 );
942 assert_eq!(compiled.read_only_properties, vec!["Arn".to_string(), "Nested.Id".to_string()]);
943 assert_eq!(compiled.write_only_properties, vec!["Secret".to_string()]);
944 assert_eq!(compiled.create_only_properties, vec!["Name".to_string()]);
945 assert_eq!(compiled.deprecated_properties, vec!["Old".to_string()]);
946 assert_eq!(compiled.conditional_create_only_properties, vec!["Maybe".to_string()]);
947 assert_eq!(compiled.primary_identifier, vec!["Name".to_string()]);
948 }
949
950 #[test]
951 fn splits_all_of_into_plain_and_conditional_entries() {
952 let compiled = compile_schema(
953 "AWS::Test::T",
954 &json!({
955 "allOf": [
956 { "required": ["Plain"] },
957 { "if": { "properties": { "A": { "enum": ["x"] } } }, "then": { "required": ["B"] } },
958 { "if": { "properties": { "A": { "enum": ["y"] } } } }
959 ]
960 }),
961 );
962 assert_eq!(compiled.all_of.len(), 1, "plain entries stay in all_of");
963 assert_eq!(compiled.all_of[0].required, vec!["Plain".to_string()]);
964 assert_eq!(compiled.if_then_else.len(), 1, "an if with neither then nor else is dropped");
965 assert_eq!(compiled.if_then_else[0].then_schema.as_ref().expect("then branch").required, vec!["B".to_string()]);
966 }
967
968 #[test]
969 fn preserves_unique_items_presence() {
970 let compiled = compile_schema(
971 "AWS::Test::T",
972 &json!({
973 "properties": {
974 "Strict": { "type": "array", "uniqueItems": true },
975 "Relaxed": { "type": "array", "uniqueItems": false },
976 "Silent": { "type": "array" }
977 }
978 }),
979 );
980 assert_eq!(compiled.properties["Strict"].unique_items, Some(true));
981 assert_eq!(
982 compiled.properties["Relaxed"].unique_items,
983 Some(false),
984 "an explicit false must be distinguishable from an omitted keyword"
985 );
986 assert_eq!(compiled.properties["Silent"].unique_items, None);
987 }
988
989 #[test]
990 fn unique_items_serialization_only_emits_true() {
991 let compiled = compile_schema(
994 "AWS::Test::T",
995 &json!({
996 "properties": {
997 "Strict": { "uniqueItems": true },
998 "Relaxed": { "uniqueItems": false },
999 "Silent": {}
1000 }
1001 }),
1002 );
1003 let json = serde_json::to_value(&compiled).expect("a compiled schema serializes");
1004 let properties = json.get("properties").and_then(|v| v.as_object()).expect("properties are serialized");
1005 assert_eq!(properties["Strict"].get("unique_items"), Some(&json!(true)));
1006 assert!(properties["Relaxed"].get("unique_items").is_none(), "explicit false must be omitted");
1007 assert!(properties["Silent"].get("unique_items").is_none(), "an absent keyword must be omitted");
1008 }
1009
1010 #[test]
1011 fn compiles_both_enum_representations_and_constraint_keywords() {
1012 let compiled = compile_schema(
1013 "AWS::Test::T",
1014 &json!({
1015 "properties": {
1016 "Exact": { "enum": ["a", "b"] },
1017 "Insensitive": { "enumCaseInsensitive": ["a", "b"] },
1018 "Excluded": { "not": { "enum": ["bad"] } },
1019 "ExcludedConst": { "not": { "const": "bad" } },
1020 "Fixed": { "const": 7 }
1021 },
1022 "dependentRequired": { "A": ["B"] },
1023 "dependentExcluded": { "C": ["D"] },
1024 "requiredOr": ["A", "B"],
1025 "requiredXor": ["C", "D"]
1026 }),
1027 );
1028 assert_eq!(compiled.properties["Exact"].enum_values, vec![json!("a"), json!("b")]);
1029 assert_eq!(compiled.properties["Insensitive"].enum_case_insensitive, vec![json!("a"), json!("b")]);
1030 assert_eq!(compiled.properties["Excluded"].not_enum, vec![json!("bad")]);
1031 assert_eq!(compiled.properties["ExcludedConst"].not_enum, vec![json!("bad")]);
1032 assert_eq!(compiled.properties["Fixed"].const_value, Some(json!(7)));
1033 assert_eq!(compiled.dependent_required.get("A"), Some(&vec!["B".to_string()]));
1034 assert_eq!(compiled.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
1035 assert_eq!(compiled.required_or, vec!["A".to_string(), "B".to_string()]);
1036 assert_eq!(compiled.required_xor, vec!["C".to_string(), "D".to_string()]);
1037 }
1038
1039 #[test]
1040 fn compiles_nested_properties_items_and_pattern_properties() {
1041 let compiled = compile_schema(
1042 "AWS::Test::T",
1043 &json!({
1044 "properties": {
1045 "Cfg": { "type": "object", "properties": { "Inner": { "type": "string" } } },
1046 "Arr": { "type": "array", "items": { "type": "string", "maxLength": 3 } },
1047 "Map": { "type": "object", "patternProperties": { "^k$": { "type": "integer" } } }
1048 }
1049 }),
1050 );
1051 assert!(compiled.properties["Cfg"].properties["Inner"].prop_type.is_some(), "nested type is compiled");
1052 assert_eq!(compiled.properties["Arr"].items.as_ref().expect("items").max_length, Some(3));
1053 assert!(compiled.properties["Map"].pattern_properties.contains_key("^k$"));
1054 }
1055
1056 #[test]
1059 fn compile_sub_compiles_all_prop_schema_fields() {
1060 let sub = compile_sub(
1063 &json!({
1064 "required": ["A"],
1065 "properties": { "P": { "type": "string" } },
1066 "additionalProperties": false,
1067 "dependentRequired": { "A": ["B"] },
1068 "dependentExcluded": { "C": ["D"] },
1069 "type": "object",
1070 "enum": ["x"],
1071 "pattern": "^a",
1072 "minimum": 1.0,
1073 "multipleOf": 5.0,
1074 "minLength": 2,
1075 "anyOf": [{ "required": ["Z"] }]
1076 }),
1077 RefSiblings::Enforce,
1078 );
1079 assert_eq!(sub.required, vec!["A".to_string()]);
1080 assert!(sub.properties.contains_key("P"));
1081 assert_eq!(sub.additional_properties, Some(false));
1082 assert_eq!(sub.dependent_required.get("A"), Some(&vec!["B".to_string()]));
1083 assert_eq!(sub.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
1084 assert!(sub.prop_type.is_some());
1085 assert_eq!(sub.enum_values, vec![json!("x")]);
1086 assert_eq!(sub.pattern.as_deref(), Some("^a"));
1087 assert_eq!(sub.minimum, Some(1.0));
1088 assert_eq!(sub.multiple_of, Some(5.0));
1089 assert_eq!(sub.min_length, Some(2));
1090 assert_eq!(sub.any_of.len(), 1);
1091 }
1092
1093 #[test]
1094 fn compile_condition_schema_fields_matches_behavior() {
1095 let cond = compile_condition_schema(
1098 &json!({
1099 "properties": { "A": { "enum": ["x"] } },
1100 "required": ["A"],
1101 "type": "object",
1102 "allOf": [{ "required": ["E"] }],
1103 "anyOf": [{ "properties": { "B": { "enum": ["y"] } } }],
1104 "oneOf": [{ "required": ["C"] }, { "properties": { "C": false } }],
1105 "not": { "required": ["D"] }
1106 }),
1107 RefSiblings::Enforce,
1108 );
1109 assert!(cond.properties.contains_key("A"));
1110 assert_eq!(cond.required, vec!["A".to_string()]);
1111 assert_eq!(cond.all_of.len(), 1);
1112 assert_eq!(cond.any_of.len(), 1);
1113 assert_eq!(cond.one_of.len(), 2);
1114 assert_eq!(
1115 cond.one_of[1].absent,
1116 vec!["C".to_string()],
1117 "a `false` property schema means the property is absent"
1118 );
1119 assert_eq!(cond.not.as_ref().map(|negated| negated.required.clone()), Some(vec!["D".to_string()]));
1120 assert!(cond.prop_type.is_some(), "the condition's type must be compiled");
1121 assert_eq!(
1122 keywords::COMPILE_CONDITION_SCHEMA_FIELDS,
1123 &[
1124 keywords::PROPERTIES,
1125 keywords::REQUIRED,
1126 keywords::TYPE,
1127 keywords::ALL_OF,
1128 keywords::ANY_OF,
1129 keywords::ONE_OF,
1130 keywords::NOT
1131 ]
1132 );
1133 }
1134
1135 #[test]
1136 fn negated_property_pair_compiles_to_conditional_exclusions() {
1137 let compiled = compile_schema(
1138 "AWS::Test::NetworkInterface",
1139 &json!({
1140 "properties": {
1141 "Ipv6AddressCount": { "type": "integer" },
1142 "Ipv6Addresses": { "type": "array" }
1143 },
1144 "allOf": [{
1145 "not": {
1146 "properties": {
1147 "Ipv6AddressCount": { "minimum": 1, "type": "integer" },
1148 "Ipv6Addresses": { "minItems": 1, "type": "array" }
1149 },
1150 "required": ["Ipv6AddressCount", "Ipv6Addresses"]
1151 }
1152 }]
1153 }),
1154 );
1155
1156 assert!(compiled.all_of.is_empty(), "the negated pair must not compile to an empty allOf branch");
1157 assert_eq!(compiled.if_then_else.len(), 1);
1158 let conditional = &compiled.if_then_else[0];
1159 assert_eq!(conditional.condition.required, vec!["Ipv6AddressCount".to_string(), "Ipv6Addresses".to_string()]);
1160 assert_eq!(conditional.condition.properties["Ipv6AddressCount"].minimum, Some(1.0));
1161 assert_eq!(conditional.condition.properties["Ipv6Addresses"].min_items, Some(1));
1162 let exclusions = &conditional.then_schema.as_ref().expect("the matching pair is excluded").dependent_excluded;
1163 assert_eq!(exclusions["Ipv6AddressCount"], vec!["Ipv6Addresses".to_string()]);
1164 assert_eq!(exclusions["Ipv6Addresses"], vec!["Ipv6AddressCount".to_string()]);
1165
1166 let serialized = serde_json::to_string(&compiled).expect("the compiled schema serializes");
1167 assert!(!serialized.contains(r#""all_of":[{}]"#));
1168 }
1169
1170 #[test]
1171 fn negated_and_exclusive_conditions_survive_compilation() {
1172 let compiled = compile_schema(
1173 "AWS::Test::Domain",
1174 &json!({
1175 "properties": { "Domain": { "type": "string" }, "CustomDomainConfig": { "type": "object" } },
1176 "allOf": [
1177 {
1178 "if": { "not": { "required": ["CustomDomainConfig"] } },
1179 "then": { "properties": { "Domain": { "pattern": "^[a-z0-9-]+$" } } }
1180 },
1181 {
1182 "if": { "oneOf": [
1183 { "properties": { "Type": { "const": "application" } }, "required": ["Type"] },
1184 { "properties": { "Type": false } }
1185 ] },
1186 "then": { "properties": { "Subnets": { "minItems": 2 } } }
1187 }
1188 ]
1189 }),
1190 );
1191 let negated = compiled.if_then_else[0].condition.not.as_ref().expect("`not` is compiled");
1192 assert_eq!(negated.required, vec!["CustomDomainConfig".to_string()]);
1193 let exclusive = &compiled.if_then_else[1].condition.one_of;
1194 assert_eq!(exclusive.len(), 2);
1195 assert!(exclusive[0].properties.contains_key("Type"));
1196 assert_eq!(exclusive[1].absent, vec!["Type".to_string()]);
1197 }
1198
1199 #[test]
1200 fn root_conditional_audit_accepts_the_supported_grammar() {
1201 let schema = json!({
1202 "allOf": [
1203 { "if": { "allOf": [{ "required": ["A"] }, { "not": { "required": ["B"] } }] }, "then": {} },
1204 { "if": { "required": ["A"] }, "then": {} },
1205 { "if": { "not": { "required": ["B"] } }, "then": {} },
1206 { "if": { "oneOf": [{ "properties": { "T": { "const": "x" } }, "required": ["T"] }, { "properties": { "T": false } }] }, "then": {} },
1207 { "if": { "anyOf": [{ "required": ["C"] }], "description": "annotation" }, "then": {} },
1208 { "properties": { "Plain": { "type": "string" } } }
1209 ]
1210 });
1211 assert!(unsupported_root_conditionals("AWS::Test::T", &schema).is_empty());
1212 }
1213
1214 #[test]
1215 fn root_conditional_audit_rejects_unrepresented_keywords_and_empty_conditions() {
1216 let schema = json!({
1217 "allOf": [
1218 { "if": { "minimum": 1 }, "then": {} },
1219 { "if": { "description": "says nothing" }, "then": {} },
1220 { "if": { "oneOf": [{ "maximum": 60 }] }, "then": {} }
1221 ]
1222 });
1223 let findings = unsupported_root_conditionals("AWS::Test::T", &schema);
1224 assert_eq!(findings.len(), 5, "{findings:?}");
1225 assert!(findings[0].contains("allOf[0].if") && findings[0].contains("\"minimum\""));
1226 assert!(findings[1].contains("allOf[0].if states no condition"));
1227 assert!(findings[2].contains("allOf[1].if states no condition"));
1228 assert!(findings[3].contains("allOf[2].if.oneOf[0]") && findings[3].contains("\"maximum\""));
1229 assert!(findings[4].contains("allOf[2].if.oneOf[0] states no condition"));
1230 }
1231
1232 #[test]
1233 fn numeric_constraints_compile_into_option_f64_fields() {
1234 let compiled = compile_schema(
1235 "AWS::Test::T",
1236 &json!({
1237 "properties": {
1238 "P": {
1239 "type": "number",
1240 "minimum": 1.5,
1241 "maximum": 99.9,
1242 "exclusiveMinimum": 0.1,
1243 "exclusiveMaximum": 100.0
1244 }
1245 }
1246 }),
1247 );
1248 let p = &compiled.properties["P"];
1249 assert_eq!(p.minimum, Some(1.5));
1250 assert_eq!(p.maximum, Some(99.9));
1251 assert_eq!(p.exclusive_minimum, Some(0.1));
1252 assert_eq!(p.exclusive_maximum, Some(100.0));
1253 }
1254
1255 #[test]
1256 fn u64_constraints_compile_into_option_u64_fields() {
1257 let compiled = compile_schema(
1258 "AWS::Test::T",
1259 &json!({
1260 "properties": {
1261 "P": {
1262 "type": "string",
1263 "minLength": 1,
1264 "maxLength": 256
1265 },
1266 "A": {
1267 "type": "array",
1268 "minItems": 0,
1269 "maxItems": 10
1270 },
1271 "O": {
1272 "type": "object",
1273 "minProperties": 1,
1274 "maxProperties": 5
1275 }
1276 }
1277 }),
1278 );
1279 assert_eq!(compiled.properties["P"].min_length, Some(1));
1280 assert_eq!(compiled.properties["P"].max_length, Some(256));
1281 assert_eq!(compiled.properties["A"].min_items, Some(0));
1282 assert_eq!(compiled.properties["A"].max_items, Some(10));
1283 assert_eq!(compiled.properties["O"].min_properties, Some(1));
1284 assert_eq!(compiled.properties["O"].max_properties, Some(5));
1285 }
1286
1287 #[test]
1288 fn string_constraints_compile_into_option_string_fields() {
1289 let compiled = compile_schema(
1290 "AWS::Test::T",
1291 &json!({
1292 "description": "resource documentation",
1293 "properties": {
1294 "P": { "type": "string", "pattern": "^x$", "format": "uri", "description": "desc" }
1295 },
1296 "replacementStrategy": "delete_then_create",
1297 "documentationUrl": "https://docs",
1298 "sourceUrl": "https://src"
1299 }),
1300 );
1301 let p = &compiled.properties["P"];
1302 assert_eq!(p.pattern.as_deref(), Some("^x$"));
1303 assert_eq!(p.format.as_deref(), Some("uri"));
1304 assert_eq!(compiled.replacement_strategy.as_deref(), Some("delete_then_create"));
1305 assert_eq!(compiled.documentation_url.as_deref(), Some("https://docs"));
1306 assert_eq!(compiled.source_url.as_deref(), Some("https://src"));
1307 let serialized = serde_json::to_string(&compiled).expect("compiled schema serializes");
1310 assert!(!serialized.contains("description"), "description must not reach the compiled schema: {serialized}");
1311 }
1312
1313 #[test]
1314 fn metadata_pointer_arrays_all_strip_properties_prefix() {
1315 let compiled = compile_schema(
1318 "AWS::Test::T",
1319 &json!({
1320 "readOnlyProperties": ["/properties/A"],
1321 "writeOnlyProperties": ["/properties/B"],
1322 "createOnlyProperties": ["/properties/C"],
1323 "deprecatedProperties": ["/properties/D"],
1324 "conditionalCreateOnlyProperties": ["/properties/E"],
1325 "primaryIdentifier": ["/properties/F"]
1326 }),
1327 );
1328 assert_eq!(compiled.read_only_properties, vec!["A"]);
1329 assert_eq!(compiled.write_only_properties, vec!["B"]);
1330 assert_eq!(compiled.create_only_properties, vec!["C"]);
1331 assert_eq!(compiled.deprecated_properties, vec!["D"]);
1332 assert_eq!(compiled.conditional_create_only_properties, vec!["E"]);
1333 assert_eq!(compiled.primary_identifier, vec!["F"]);
1334 }
1335
1336 #[test]
1337 fn definitions_ref_prefix_is_used_in_compile_prop() {
1338 let compiled = compile_schema(
1339 "AWS::Test::T",
1340 &json!({
1341 "properties": { "P": { "$ref": "#/definitions/D" } },
1342 "definitions": { "D": { "type": "object" } }
1343 }),
1344 );
1345 assert_eq!(compiled.properties["P"].ref_name.as_deref(), Some("D"));
1346 }
1347
1348 #[test]
1349 fn schema_maps_slice_matches_recursive_compilation() {
1350 let compiled = compile_schema(
1352 "AWS::Test::T",
1353 &json!({
1354 "properties": { "P": { "type": "string" } },
1355 "definitions": { "D": { "type": "object" } }
1356 }),
1357 );
1358 assert!(compiled.properties.contains_key("P"));
1359 assert!(compiled.definitions.contains_key("D"));
1360 let compiled_prop = compile_schema(
1362 "AWS::Test::T",
1363 &json!({
1364 "properties": {
1365 "Map": { "type": "object", "patternProperties": { "^k$": { "type": "string" } } }
1366 }
1367 }),
1368 );
1369 assert!(compiled_prop.properties["Map"].pattern_properties.contains_key("^k$"));
1370 }
1371
1372 #[test]
1373 fn ref_siblings_enforce_keeps_constraints_beside_a_ref() {
1374 let compiled = compile_schema_with(
1375 "AWS::Test::T",
1376 &json!({
1377 "properties": { "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" } },
1378 "definitions": { "D": { "type": "string" } }
1379 }),
1380 RefSiblings::Enforce,
1381 );
1382 let prop = &compiled.properties["P"];
1383 assert_eq!(prop.ref_name.as_deref(), Some("D"));
1384 assert_eq!(prop.max_length, Some(3), "Enforce must keep constraint siblings");
1385 assert_eq!(prop.pattern.as_deref(), Some("^a"));
1386 }
1387
1388 #[test]
1389 fn ref_siblings_ignore_drops_everything_beside_a_ref() {
1390 let compiled = compile_schema_with(
1395 "AWS::Test::T",
1396 &json!({
1397 "properties": {
1398 "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" },
1399 "Q": { "$ref": "not-a-definitions-ref", "maxLength": 3 }
1400 },
1401 "definitions": { "D": { "type": "string" } }
1402 }),
1403 RefSiblings::Ignore,
1404 );
1405 let prop = &compiled.properties["P"];
1406 assert_eq!(prop.ref_name.as_deref(), Some("D"));
1407 assert_eq!(prop.max_length, None, "Ignore must drop constraint siblings");
1408 assert_eq!(prop.pattern, None);
1409 let unresolvable = &compiled.properties["Q"];
1410 assert_eq!(unresolvable.ref_name, None);
1411 assert_eq!(
1412 unresolvable.max_length, None,
1413 "a $ref outside definitions compiles to an unconstrained property under Ignore, matching the \
1414 historical bundled artifact"
1415 );
1416 }
1417
1418 #[test]
1419 fn condition_schema_type_is_compiled() {
1420 let cond = compile_condition_schema(&json!({ "type": "object", "required": ["A"] }), RefSiblings::Enforce);
1421 match cond.prop_type.as_ref().expect("the condition's type must be compiled") {
1422 PropType::Single(name) => assert_eq!(name, "object"),
1423 PropType::Multi(names) => panic!("expected a single type, got {names:?}"),
1424 }
1425 }
1426}