use serde_json::Value;
use std::collections::{HashMap, HashSet};
pub(crate) struct EffectiveSchema {
pub properties: HashMap<String, Value>,
pub required: HashSet<String>,
pub additional_properties: Option<Value>,
}
pub(crate) fn extract_effective_schema(schema: &Value) -> EffectiveSchema {
let mut eff = EffectiveSchema {
properties: HashMap::new(),
required: HashSet::new(),
additional_properties: None,
};
if let Value::Object(map) = schema {
if let Some(Value::Object(props)) = map.get("properties") {
for (k, v) in props {
eff.properties.insert(k.clone(), v.clone());
}
}
if let Some(Value::Array(req)) = map.get("required") {
for v in req {
if let Value::String(s) = v {
eff.required.insert(s.clone());
}
}
}
if let Some(ap) = map.get("additionalProperties") {
eff.additional_properties = Some(ap.clone());
}
if let Some(Value::Array(all_of)) = map.get("allOf") {
for item in all_of {
let item_eff = extract_effective_schema(item);
eff.properties.extend(item_eff.properties);
eff.required.extend(item_eff.required);
if item_eff.additional_properties.is_some() {
eff.additional_properties = item_eff.additional_properties;
}
}
}
}
eff
}
pub(crate) fn validate_schema_compatibility(
base: &EffectiveSchema,
derived: &EffectiveSchema,
base_id: &str,
derived_id: &str,
) -> Vec<String> {
let mut errors = Vec::new();
let base_disallows_additional = matches!(base.additional_properties, Some(Value::Bool(false)));
for (prop_name, derived_prop) in &derived.properties {
if let Some(base_prop) = base.properties.get(prop_name) {
if *derived_prop == Value::Bool(false) {
errors.push(format!(
"property '{prop_name}': derived schema '{derived_id}' disables property defined in base '{base_id}'"
));
continue;
}
compare_property_constraints(base_prop, derived_prop, prop_name, &mut errors);
}
else if base_disallows_additional {
errors.push(format!(
"property '{prop_name}': derived schema '{derived_id}' adds new property but base '{base_id}' has additionalProperties: false"
));
}
}
if base_disallows_additional {
let derived_allows_additional =
!matches!(derived.additional_properties, Some(Value::Bool(false)));
if derived_allows_additional {
errors.push(format!(
"derived schema '{derived_id}' loosens additionalProperties from false in base '{base_id}'"
));
}
}
check_required_removal(base, derived, base_id, derived_id, &mut errors);
errors
}
fn compare_property_constraints(
base_prop: &Value,
derived_prop: &Value,
prop_name: &str,
errors: &mut Vec<String>,
) {
let Some(base_map) = base_prop.as_object() else {
return;
};
let Some(derived_map) = derived_prop.as_object() else {
errors.push(format!(
"property '{prop_name}': derived replaces schema object with a non-object value, \
loosening base constraints"
));
return;
};
check_type_compatibility(base_map, derived_map, prop_name, errors);
let derived_values = collect_derived_enumerated_values(derived_map);
let derived_enumerates_values = derived_values.is_some();
check_const_compatibility(base_map, derived_map, prop_name, errors);
if derived_enumerates_values {
check_enumerated_values_against_base(
base_map,
derived_values.as_deref().unwrap_or(&[]),
prop_name,
errors,
);
} else {
check_pattern_compatibility(base_map, derived_map, prop_name, errors);
check_upper_bound(base_map, derived_map, "maxLength", prop_name, errors);
check_upper_bound(base_map, derived_map, "maximum", prop_name, errors);
check_upper_bound(base_map, derived_map, "maxItems", prop_name, errors);
check_lower_bound(base_map, derived_map, "minLength", prop_name, errors);
check_lower_bound(base_map, derived_map, "minimum", prop_name, errors);
check_lower_bound(base_map, derived_map, "minItems", prop_name, errors);
}
check_enum_compatibility(base_map, derived_map, prop_name, errors);
check_items_compatibility(base_map, derived_map, prop_name, errors);
if base_map.get("type") == Some(&Value::String("object".to_owned()))
&& derived_map.get("type") == Some(&Value::String("object".to_owned()))
&& base_map.contains_key("properties")
{
let base_nested = extract_effective_schema(base_prop);
let derived_nested = extract_effective_schema(derived_prop);
let nested_errors =
validate_schema_compatibility(&base_nested, &derived_nested, "base", "derived");
for err in nested_errors {
errors.push(format!("in nested object '{prop_name}': {err}"));
}
}
}
fn check_type_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let (Some(base_type), Some(derived_type)) = (base_map.get("type"), derived_map.get("type"))
&& base_type != derived_type
{
errors.push(format!(
"property '{prop_name}': derived changes type from {base_type} to {derived_type}"
));
}
}
fn check_const_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_const) = base_map.get("const") {
match derived_map.get("const") {
Some(derived_const) if base_const != derived_const => {
errors.push(format!(
"property '{prop_name}': derived redefines const from {base_const} to {derived_const}"
));
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits const constraint ({base_const}) defined in base"
));
}
_ => {} }
}
}
fn check_pattern_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_pat) = base_map.get("pattern") {
match derived_map.get("pattern") {
Some(derived_pat) if base_pat != derived_pat => {
errors.push(format!(
"property '{prop_name}': derived changes pattern from {base_pat} to {derived_pat}"
));
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits pattern constraint ({base_pat}) defined in base"
));
}
_ => {} }
}
}
fn check_enum_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(Value::Array(base_enum)) = base_map.get("enum") {
if let Some(Value::Array(derived_enum)) = derived_map.get("enum") {
for val in derived_enum {
if !base_enum.contains(val) {
errors.push(format!(
"property '{prop_name}': derived enum contains value {val} not in base enum"
));
}
}
return;
}
if let Some(derived_const) = derived_map.get("const") {
if !base_enum.contains(derived_const) {
errors.push(format!(
"property '{prop_name}': derived const {derived_const} is not in base enum"
));
}
return;
}
errors.push(format!(
"property '{prop_name}': derived omits enum constraint defined in base"
));
}
}
fn check_items_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_items) = base_map.get("items") {
match derived_map.get("items") {
Some(derived_items) => {
let items_name = format!("{prop_name}.items");
compare_property_constraints(base_items, derived_items, &items_name, errors);
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits items constraint defined in base"
));
}
}
}
}
fn check_required_removal(
base: &EffectiveSchema,
derived: &EffectiveSchema,
base_id: &str,
derived_id: &str,
errors: &mut Vec<String>,
) {
if derived.required.is_empty() {
return;
}
for base_req in &base.required {
if !derived.required.contains(base_req) {
errors.push(format!(
"derived schema '{derived_id}' removes required field '{base_req}' defined in base '{base_id}'"
));
}
}
}
fn check_upper_bound(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
keyword: &str,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_val) = base_map.get(keyword) {
match derived_map.get(keyword) {
Some(derived_val) => {
if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64())
&& d > b
{
errors.push(format!(
"property '{prop_name}': derived {keyword} ({d}) exceeds base {keyword} ({b})"
));
}
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base"
));
}
}
}
}
fn check_lower_bound(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
keyword: &str,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_val) = base_map.get(keyword) {
match derived_map.get(keyword) {
Some(derived_val) => {
if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64())
&& d < b
{
errors.push(format!(
"property '{prop_name}': derived {keyword} ({d}) is less than base {keyword} ({b})"
));
}
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base"
));
}
}
}
}
fn collect_derived_enumerated_values(
derived_map: &serde_json::Map<String, Value>,
) -> Option<Vec<Value>> {
if let Some(c) = derived_map.get("const") {
return Some(vec![c.clone()]);
}
if let Some(Value::Array(arr)) = derived_map.get("enum") {
return Some(arr.clone());
}
None
}
fn check_enumerated_values_against_base(
base_map: &serde_json::Map<String, Value>,
values: &[Value],
prop_name: &str,
errors: &mut Vec<String>,
) {
for keyword in &["minimum", "minLength", "minItems"] {
if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) {
for val in values {
let numeric: Option<f64> = match *keyword {
"minLength" => val
.as_str()
.and_then(|s| u32::try_from(s.len()).ok())
.map(f64::from),
"minItems" => val
.as_array()
.and_then(|a| u32::try_from(a.len()).ok())
.map(f64::from),
_ => val.as_f64(),
};
if let Some(n) = numeric
&& n < base_val
{
errors.push(format!(
"property '{prop_name}': derived const/enum value {val} violates \
base {keyword} ({base_val})"
));
}
}
}
}
for keyword in &["maximum", "maxLength", "maxItems"] {
if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) {
for val in values {
let numeric: Option<f64> = match *keyword {
"maxLength" => val
.as_str()
.and_then(|s| u32::try_from(s.len()).ok())
.map(f64::from),
"maxItems" => val
.as_array()
.and_then(|a| u32::try_from(a.len()).ok())
.map(f64::from),
_ => val.as_f64(),
};
if let Some(n) = numeric
&& n > base_val
{
errors.push(format!(
"property '{prop_name}': derived const/enum value {val} violates \
base {keyword} ({base_val})"
));
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_extract_simple_schema() {
let schema = json!({
"type": "object",
"required": ["a"],
"properties": {
"a": {"type": "string"},
"b": {"type": "integer"}
},
"additionalProperties": false
});
let eff = extract_effective_schema(&schema);
assert_eq!(eff.properties.len(), 2);
assert!(eff.required.contains("a"));
assert_eq!(eff.additional_properties, Some(Value::Bool(false)));
}
#[test]
fn test_extract_with_allof() {
let schema = json!({
"type": "object",
"allOf": [
{
"type": "object",
"required": ["x"],
"properties": {"x": {"type": "string"}}
},
{
"type": "object",
"required": ["y"],
"properties": {"y": {"type": "number"}}
}
]
});
let eff = extract_effective_schema(&schema);
assert_eq!(eff.properties.len(), 2);
assert!(eff.required.contains("x"));
assert!(eff.required.contains("y"));
}
#[test]
fn test_compatible_tightening() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 50}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(errs.is_empty(), "tightening should be ok: {errs:?}");
}
#[test]
fn test_incompatible_loosening_max_length() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 200}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(!errs.is_empty());
}
#[test]
fn test_incompatible_loosening_maximum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"n": {"type": "integer", "maximum": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"n": {"type": "integer", "maximum": 200}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_incompatible_loosening_minimum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 10}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 5}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_enum_expansion_fails() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b"]}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b", "c"]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_enum_subset_ok() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b", "c"]}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a"]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn test_additional_properties_false_blocks_new_prop() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": false
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_open_base_allows_new_prop() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {"a": {"type": "string"}}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn test_property_disabled_fails() {
let base = extract_effective_schema(&json!({
"type": "object",
"required": ["x"],
"properties": {"x": {"type": "string"}}
}));
let mut derived_props = HashMap::new();
derived_props.insert("x".to_owned(), Value::Bool(false));
let derived = EffectiveSchema {
properties: derived_props,
required: HashSet::new(),
additional_properties: None,
};
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_nested_object_loosening_caught() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {
"v": {"type": "integer", "maximum": 10}
}
}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {
"v": {"type": "integer", "maximum": 20}
}
}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_boolean_true_schema_loosens_constrained_property() {
let base = EffectiveSchema {
properties: {
let mut m = HashMap::new();
m.insert("age".to_owned(), json!({"type": "integer", "maximum": 120}));
m
},
required: HashSet::new(),
additional_properties: None,
};
let derived = EffectiveSchema {
properties: {
let mut m = HashMap::new();
m.insert("age".to_owned(), Value::Bool(true));
m
},
required: HashSet::new(),
additional_properties: None,
};
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Boolean true schema should be flagged as loosening: {errs:?}"
);
}
#[test]
fn test_boolean_true_schema_ok_when_base_unconstrained() {
let base = EffectiveSchema {
properties: {
let mut m = HashMap::new();
m.insert("name".to_owned(), json!({"type": "string"}));
m
},
required: HashSet::new(),
additional_properties: None,
};
let derived = EffectiveSchema {
properties: {
let mut m = HashMap::new();
m.insert("name".to_owned(), Value::Bool(true));
m
},
required: HashSet::new(),
additional_properties: None,
};
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Boolean true schema replaces typed property - should flag"
);
}
#[test]
fn test_enum_tightening_allows_omitting_bounds() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"tier": {"type": "string", "maxLength": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"tier": {"type": "string", "enum": ["gold", "platinum"]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"enum tightening should allow omitting maxLength: {errs:?}"
);
}
#[test]
fn test_const_tightening_allows_omitting_bounds_and_pattern() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100, "pattern": "^[a-z]+$"}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"v": {"type": "string", "const": "hello"}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"const tightening should allow omitting maxLength and pattern: {errs:?}"
);
}
#[test]
fn test_enum_tightening_allows_omitting_numeric_bounds() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"priority": {"type": "integer", "minimum": 0, "maximum": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"priority": {"type": "integer", "enum": [1, 5, 10]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"enum tightening should allow omitting min/max: {errs:?}"
);
}
#[test]
fn test_omitting_bounds_without_enum_or_const_still_fails() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"code": {"type": "string", "maxLength": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"code": {"type": "string"}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Omitting maxLength without enum/const should still fail"
);
}
#[test]
fn test_derived_const_must_be_in_base_enum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["active", "inactive"]}
}
}));
let derived_ok = extract_effective_schema(&json!({
"type": "object",
"properties": {
"status": {"type": "string", "const": "active"}
}
}));
let errs = validate_schema_compatibility(&base, &derived_ok, "b", "d");
assert!(errs.is_empty(), "const in base enum should be ok: {errs:?}");
let derived_bad = extract_effective_schema(&json!({
"type": "object",
"properties": {
"status": {"type": "string", "const": "deleted"}
}
}));
let errs = validate_schema_compatibility(&base, &derived_bad, "b", "d");
assert!(!errs.is_empty(), "const NOT in base enum should fail");
}
#[test]
fn test_const_violates_minimum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 42}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "const": 32}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"const 32 < minimum 42 should fail: {errs:?}"
);
assert!(
errs.iter()
.any(|e| e.contains("violates") && e.contains("minimum")),
"error should mention minimum violation: {errs:?}"
);
}
#[test]
fn test_const_satisfies_minimum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 42}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "const": 50}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
errs.is_empty(),
"const 50 >= minimum 42 should pass: {errs:?}"
);
}
#[test]
fn test_enum_value_violates_maximum() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "maximum": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "enum": [10, 50, 200]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"enum value 200 > maximum 100 should fail: {errs:?}"
);
}
#[test]
fn test_enum_values_within_bounds() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 10, "maximum": 100}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"score": {"type": "integer", "enum": [10, 50, 100]}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
errs.is_empty(),
"all enum values in range should pass: {errs:?}"
);
}
#[test]
fn test_const_string_violates_max_length() {
let base = extract_effective_schema(&json!({
"type": "object",
"properties": {
"code": {"type": "string", "maxLength": 5}
}
}));
let derived = extract_effective_schema(&json!({
"type": "object",
"properties": {
"code": {"type": "string", "const": "toolong"}
}
}));
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"const 'toolong' exceeds maxLength 5: {errs:?}"
);
}
}