use std::collections::BTreeSet;
use serde_json::Value;
use super::{Applicable, Nulls};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UndeclaredKey {
pub(crate) key: String,
pub(crate) path: String,
pub(crate) declared: Vec<String>,
}
pub(crate) fn find_undeclared_key(original_schema: &Value, value: &Value) -> Option<UndeclaredKey> {
let nulls = Nulls::new(original_schema);
let applicable = nulls.expand(original_schema, value, 0);
find(&nulls, &applicable, value, "")
}
fn find<'s>(
nulls: &Nulls<'s>,
applicable: &Applicable<'s>,
value: &Value,
path: &str,
) -> Option<UndeclaredKey> {
match value {
Value::Object(map) => {
for key in map.keys() {
if status(applicable, key) == Status::Undeclared {
let mut declared = BTreeSet::new();
declared_keys(applicable, &mut declared);
return Some(UndeclaredKey {
key: key.clone(),
path: join(path, key),
declared: declared.into_iter().map(str::to_owned).collect(),
});
}
}
map.iter()
.filter(|(_, child)| child.is_object() || child.is_array())
.find_map(|(key, child)| {
let expanded = nulls.expand_all(&applicable.property(key), child);
find(nulls, &expanded, child, &join(path, key))
})
}
Value::Array(items) => items
.iter()
.enumerate()
.filter(|(_, item)| item.is_object() || item.is_array())
.find_map(|(index, item)| {
let expanded = nulls.expand_all(&applicable.item(index), item);
find(nulls, &expanded, item, &format!("{path}[{index}]"))
}),
_ => None,
}
}
fn join(path: &str, key: &str) -> String {
if path.is_empty() {
key.to_owned()
} else {
format!("{path}.{key}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status {
Declared,
OpenExplicitly,
OpenImplicitly,
Undeclared,
Silent,
}
fn status(applicable: &Applicable<'_>, key: &str) -> Status {
match applicable {
Applicable::Schema(schema) => schema_status(schema, key),
Applicable::All(members) => {
let statuses: Vec<_> = members.iter().map(|m| status(m, key)).collect();
[
Status::Declared,
Status::OpenExplicitly,
Status::Undeclared,
Status::OpenImplicitly,
]
.into_iter()
.find(|wanted| statuses.contains(wanted))
.unwrap_or(Status::Silent)
}
Applicable::Any(members) => {
let statuses: Vec<_> = members.iter().map(|m| status(m, key)).collect();
[
Status::Declared,
Status::OpenExplicitly,
Status::OpenImplicitly,
Status::Undeclared,
]
.into_iter()
.find(|wanted| statuses.contains(wanted))
.unwrap_or(Status::Silent)
}
}
}
fn schema_status(schema: &Value, key: &str) -> Status {
let Value::Object(map) = schema else {
return match schema {
Value::Bool(true) => Status::OpenExplicitly,
_ => Status::Silent,
};
};
if map
.get("type")
.is_some_and(|ty| !super::type_matches(ty, &Value::Object(Default::default())))
{
return Status::Silent;
}
let properties = map.get("properties").and_then(Value::as_object);
if properties.is_some_and(|p| p.contains_key(key)) {
return Status::Declared;
}
if map.contains_key("patternProperties") {
return Status::OpenExplicitly;
}
match map.get("additionalProperties") {
Some(Value::Bool(false)) => return Status::Undeclared,
Some(_) => return Status::OpenExplicitly,
None => {}
}
if properties.is_some() {
return Status::Undeclared;
}
if ["$ref", "allOf", "anyOf", "oneOf"]
.iter()
.any(|keyword| map.contains_key(*keyword))
{
return Status::Silent;
}
Status::OpenImplicitly
}
fn declared_keys<'s>(applicable: &Applicable<'s>, out: &mut BTreeSet<&'s str>) {
match applicable {
Applicable::Schema(schema) => {
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
out.extend(properties.keys().map(String::as_str));
}
}
Applicable::All(members) | Applicable::Any(members) => {
for member in members {
declared_keys(member, out);
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn undeclared(schema: &Value, value: Value) -> Option<(String, String)> {
find_undeclared_key(schema, &value).map(|found| (found.key, found.path))
}
#[test]
fn a_misnamed_key_is_undeclared() {
let schema = json!({
"type": "object",
"properties": {"email": {"type": ["string", "null"]}, "name": {"type": "string"}},
"required": ["name"]
});
let found = find_undeclared_key(&schema, &json!({"name": "Ada", "e_mail": "a@b"})).unwrap();
assert_eq!(found.key, "e_mail");
assert_eq!(found.path, "e_mail");
assert_eq!(found.declared, ["email", "name"]);
assert_eq!(
undeclared(&schema, json!({"name": "Ada", "email": null})),
None
);
}
#[test]
fn nested_objects_arrays_and_refs_are_walked() {
let schema = json!({
"type": "object",
"properties": {
"contact": {"$ref": "#/$defs/Contact"},
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
},
"$defs": {
"Contact": {"type": "object", "properties": {"email": {"type": "string"}}},
"Item": {"type": "object", "properties": {"colour": {"type": "string"}}}
}
});
assert_eq!(
undeclared(&schema, json!({"contact": {"e_mail": "x"}})),
Some(("e_mail".into(), "contact.e_mail".into()))
);
assert_eq!(
undeclared(
&schema,
json!({"items": [{"colour": "red"}, {"color": "blue"}]})
),
Some(("color".into(), "items[1].color".into()))
);
assert_eq!(
undeclared(&schema, json!({"email": "x", "contact": {}})),
Some(("email".into(), "email".into()))
);
}
#[test]
fn maps_flattened_maps_and_open_schemas_are_exempt() {
let map = json!({
"type": "object",
"properties": {"labels": {"type": "object", "additionalProperties": {"type": "string"}}}
});
assert_eq!(undeclared(&map, json!({"labels": {"anything": "x"}})), None);
let flattened = json!({
"type": "object",
"properties": {"id": {"type": "string"}},
"additionalProperties": true
});
assert_eq!(undeclared(&flattened, json!({"id": "1", "extra": 2})), None);
let pattern = json!({"type": "object", "properties": {}, "patternProperties": {"^x": {}}});
assert_eq!(undeclared(&pattern, json!({"y": 1})), None);
for open in [json!(true), json!({}), json!({"type": "object"})] {
let schema = json!({"type": "object", "properties": {"data": open}});
assert_eq!(
undeclared(&schema, json!({"data": {"k": 1}})),
None,
"{schema}"
);
}
let nested = json!({
"type": "object",
"additionalProperties": {"type": "object", "properties": {"a": {}}}
});
assert_eq!(
undeclared(&nested, json!({"k": {"b": 1}})),
Some(("b".into(), "k.b".into()))
);
}
#[test]
fn a_closed_object_rejects_like_deny_unknown_fields() {
let schema = json!({
"type": "object",
"properties": {"a": {}},
"additionalProperties": false
});
assert_eq!(
undeclared(&schema, json!({"b": 1})),
Some(("b".into(), "b".into()))
);
}
#[test]
fn untagged_unions_check_against_every_branch_that_may_apply() {
let schema = json!({
"anyOf": [
{"type": "object", "properties": {"email": {"type": "string"}}},
{"type": "object", "properties": {"phone": {"type": "string"}}}
]
});
assert_eq!(undeclared(&schema, json!({"phone": "1"})), None);
assert_eq!(undeclared(&schema, json!({"email": "a"})), None);
let found = find_undeclared_key(&schema, &json!({"e_mail": "a"})).unwrap();
assert_eq!(found.key, "e_mail");
assert_eq!(found.declared, ["email", "phone"]);
}
#[test]
fn an_optional_struct_does_not_open_through_its_null_branch() {
let schema = json!({
"type": "object",
"properties": {
"contact": {"anyOf": [{"$ref": "#/$defs/Contact"}, {"type": "null"}]}
},
"$defs": {
"Contact": {
"type": "object",
"properties": {"email": {"type": "string"}},
"required": ["email"]
}
}
});
assert_eq!(
undeclared(&schema, json!({"contact": {"e_mail": "x"}})),
Some(("e_mail".into(), "contact.e_mail".into()))
);
}
#[test]
fn all_of_members_and_flattened_enums_declare_their_keys() {
let schema = json!({
"type": "object",
"properties": {"id": {"type": "string"}},
"oneOf": [
{"properties": {"kind": {"const": "a"}, "a_field": {}}, "required": ["kind"]},
{"properties": {"kind": {"const": "b"}, "b_field": {}}, "required": ["kind"]}
]
});
assert_eq!(
undeclared(&schema, json!({"id": "1", "kind": "a", "a_field": 1})),
None
);
assert_eq!(
undeclared(&schema, json!({"id": "1", "kind": "a", "b_field": 1})),
Some(("b_field".into(), "b_field".into()))
);
let all_of = json!({
"allOf": [
{"type": "object", "properties": {"a": {}}},
{"type": "object", "properties": {"b": {}}}
]
});
assert_eq!(undeclared(&all_of, json!({"a": 1, "b": 2})), None);
assert_eq!(
undeclared(&all_of, json!({"c": 3})),
Some(("c".into(), "c".into()))
);
}
#[test]
fn scalars_and_cycles_terminate() {
assert_eq!(undeclared(&json!({"type": "string"}), json!("x")), None);
let cyclic = json!({"$ref": "#/$defs/A", "$defs": {"A": {"$ref": "#/$defs/A"}}});
assert_eq!(undeclared(&cyclic, json!({"k": 1})), None);
}
}