use std::{
cmp::Ordering,
collections::{hash_map::DefaultHasher, HashSet},
hash::{Hash, Hasher},
};
use jsonschema::{
canonical::{options, CanonicalKind, CanonicalSchema, CanonicalView, OperandMismatch},
canonicalize, CanonicalizationError, Draft, JsonType, PatternOptions, Registry,
};
use serde_json::{json, Map, Number, Value};
use test_case::test_case;
#[test_case(&json!({"if": {}, "unevaluatedProperties": false}); "unevaluated properties beside an applicator")]
fn unmodeled_document_round_trips_verbatim(schema: &Value) {
let canonical = canonicalize(schema).expect("canonicalizes");
assert_eq!(&canonical.to_json_schema(), schema);
assert!(matches!(canonical.view(), CanonicalView::Raw(_)));
}
#[test]
fn reference_view_exposes_its_canonical_definition() {
let canonical = canonicalize(&json!({
"$ref": "#/$defs/user",
"$defs": {
"user": {
"allOf": [
{"type": "integer"},
{"minimum": 0}
]
}
}
}))
.expect("canonicalizes");
assert_eq!(
canonical.view(),
CanonicalView::Reference("#/$defs/user".to_string())
);
let definitions: Vec<_> = canonical.definitions().collect();
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].0, "#/$defs/user");
assert!(matches!(definitions[0].1.view(), CanonicalView::Integer(_)));
}
#[test]
fn external_registry_reference_emits_a_self_contained_definition() {
let external = json!({
"$id": "https://example.com/external",
"$anchor": "value",
"type": "string"
});
let registry = Registry::new()
.add("https://example.com/external", &external)
.expect("resource URI is valid")
.prepare()
.expect("registry prepares");
let canonical = options()
.with_registry(®istry)
.canonicalize(&json!({"$ref": "https://example.com/external#value"}))
.expect("canonicalizes");
let reference =
"urn:jsonschema:canonical:https%3A%2F%2Fexample.com%2Fexternal%23value".to_string();
assert_eq!(
canonical.view(),
CanonicalView::Reference(reference.clone())
);
assert_eq!(
canonical
.definitions()
.map(|(uri, _)| uri)
.collect::<Vec<_>>(),
vec![reference]
);
let emitted = canonical.to_json_schema();
let validator =
jsonschema::validator_for(&emitted).expect("canonical output is self-contained");
assert!(validator.is_valid(&json!("value")));
assert!(!validator.is_valid(&json!(1)));
}
#[test]
fn dynamic_reference_only_in_an_external_resource_is_modeled() {
let external = json!({
"$id": "https://example.com/list",
"$dynamicRef": "#num",
"$defs": {
"n": {"$dynamicAnchor": "num", "type": "number"}
}
});
let registry = Registry::new()
.add("https://example.com/list", &external)
.expect("resource URI is valid")
.prepare()
.expect("registry prepares");
let canonical = options()
.with_registry(®istry)
.canonicalize(&json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "https://example.com/list"
}))
.expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::Reference);
let emitted = canonical.to_json_schema();
let validator =
jsonschema::validator_for(&emitted).expect("canonical output is self-contained");
assert!(validator.is_valid(&json!(1)));
assert!(!validator.is_valid(&json!("x")));
}
fn assert_validation_parity(schema: &Value, instances: &[Value]) {
let canonical = canonicalize(schema)
.expect("canonicalizes")
.to_json_schema();
let raw = jsonschema::validator_for(schema).expect("raw builds");
let canon = jsonschema::validator_for(&canonical).expect("canonical builds");
for instance in instances {
assert_eq!(
raw.is_valid(instance),
canon.is_valid(instance),
"parity disagrees on {instance}\n canonical = {canonical}"
);
}
}
#[test]
fn dynamic_anchor_in_a_const_value_does_not_bind() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/phantom/root",
"properties": {
"marker": {"const": {"$dynamicAnchor": "T"}},
"a": {"$ref": "num"},
"b": {"$ref": "str"}
},
"$defs": {
"num": {
"$id": "num",
"$defs": {"bind": {"$dynamicAnchor": "T", "type": "number"}},
"$ref": "shared"
},
"str": {
"$id": "str",
"$defs": {"bind": {"$dynamicAnchor": "T", "type": "string"}},
"$ref": "shared"
},
"shared": {
"$id": "shared",
"$defs": {"fallback": {"$dynamicAnchor": "T", "type": "boolean"}},
"items": {"$dynamicRef": "#T"}
}
}
});
assert_validation_parity(
&schema,
&[
json!({"a": [1]}),
json!({"a": ["s"]}),
json!({"b": ["s"]}),
json!({"b": [1]}),
],
);
let once = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
let twice = canonicalize(&once)
.expect("canonicalizes again")
.to_json_schema();
assert_eq!(once, twice);
}
#[test]
fn recursive_ref_chain_breaks_at_an_anchorless_resource() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://example.com/chain/root",
"$recursiveAnchor": true,
"properties": {
"direct": {"$ref": "shared"},
"via": {"$ref": "m"}
},
"$defs": {
"m": {"$id": "m", "$ref": "a2"},
"a2": {"$id": "a2", "$recursiveAnchor": true, "maxProperties": 1, "$ref": "shared"},
"shared": {
"$id": "shared",
"$recursiveAnchor": true,
"properties": {"deep": {"$recursiveRef": "#"}}
}
}
});
assert_validation_parity(
&schema,
&[
json!({"direct": {"deep": {"q": 1, "r": 2}}}),
json!({"via": {"deep": {"q": 1, "r": 2}}}),
json!({"direct": {"deep": {}}}),
json!({"via": {"deep": {}}}),
],
);
}
#[test]
fn back_reference_through_an_anchored_resource_is_not_the_root() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://example.com/back/root",
"$recursiveAnchor": true,
"properties": {
"wrap": {
"$id": "x",
"$recursiveAnchor": true,
"maxProperties": 1,
"properties": {"back": {"$ref": "https://example.com/back/root"}}
},
"k": {"$recursiveRef": "#"}
}
});
assert_validation_parity(
&schema,
&[
json!({"k": {"q": 1, "r": 2}}),
json!({"wrap": {"back": {"k": {"q": 1, "r": 2}}}}),
json!({"wrap": {"back": {"k": {}}}}),
],
);
}
#[test]
fn redeclared_dynamic_anchor_in_an_embedded_resource_binds() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/redeclare/root",
"$defs": {
"rootbind": {"$dynamicAnchor": "X", "type": "object"},
"shared": {
"$id": "shared",
"$defs": {"fallback": {"$dynamicAnchor": "X", "type": "boolean"}},
"items": {"$dynamicRef": "#X"}
}
},
"properties": {
"a": {"$ref": "shared"},
"b": {
"$id": "emb",
"$defs": {"embbind": {"$dynamicAnchor": "X", "required": ["c"], "type": "object"}},
"properties": {"inner": {"$ref": "shared"}}
}
}
});
assert_validation_parity(
&schema,
&[
json!({"a": [{}]}),
json!({"b": {"inner": [{}]}}),
json!({"b": {"inner": [{"c": 1}]}}),
],
);
}
#[test_case(&json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/pointer/root",
"$defs": {
"sub": {
"$id": "sub",
"$defs": {
"bind": {"$dynamicAnchor": "T", "type": "number"},
"middle": {"$ref": "https://example.com/pointer/shared"}
}
},
"shared": {
"$id": "shared",
"$defs": {"fallback": {"$dynamicAnchor": "T", "type": "string"}},
"items": {"$dynamicRef": "#T"}
}
},
"properties": {
"a": {"$ref": "sub#/$defs/middle"},
"b": {"$ref": "shared"}
}
}) ; "pointer entry")]
#[test_case(&json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/stray/root",
"$defs": {
"holder": {
"$id": "holder",
"$defs": {
"bind": {"id": "stray", "$dynamicAnchor": "T", "type": "number"},
"use": {"$ref": "https://example.com/stray/shared"}
}
},
"shared": {
"$id": "shared",
"$defs": {"fallback": {"$dynamicAnchor": "T", "type": "string"}},
"items": {"$dynamicRef": "#T"}
}
},
"properties": {
"a": {"$ref": "holder#/$defs/use"},
"b": {"$ref": "shared"}
}
}) ; "stray id key")]
fn resource_anchors_bind_below_any_entry_point(schema: &Value) {
assert_validation_parity(
schema,
&[
json!({"a": [1]}),
json!({"a": ["s"]}),
json!({"b": ["s"]}),
json!({"b": [1]}),
],
);
}
#[test]
fn nested_recursive_anchor_does_not_bind_the_resource() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://example.com/nested/root",
"$defs": {
"phantom": {"properties": {"x": {"$recursiveAnchor": true}}},
"a": {"$id": "a", "$recursiveAnchor": true, "maxProperties": 1, "properties": {"m": {"$ref": "shared"}}},
"b": {"$id": "b", "$recursiveAnchor": true, "properties": {"m": {"$ref": "shared"}}},
"shared": {
"$id": "shared",
"$recursiveAnchor": true,
"properties": {"deep": {"$recursiveRef": "#"}}
}
},
"properties": {
"pa": {"$ref": "a"},
"pb": {"$ref": "b"}
}
});
assert_validation_parity(
&schema,
&[
json!({"pa": {"m": {"deep": {"q": 1, "r": 2}}}}),
json!({"pb": {"m": {"deep": {"q": 1, "r": 2}}}}),
json!({"pa": {"m": {"deep": {}}}}),
],
);
}
#[test]
fn relative_id_beside_ref_with_assertion_siblings_canonicalizes() {
let canonical = canonicalize(&json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/root.json",
"properties": {
"a": {
"$id": "nested/inner.json",
"$ref": "other.json",
"type": "object",
"properties": {
"b": {"$ref": "sibling.json"}
}
}
},
"$defs": {
"o": {"$id": "nested/other.json", "minProperties": 1},
"s": {"$id": "nested/sibling.json", "type": "integer"}
}
}))
.expect("canonicalizes");
let emitted = canonical.to_json_schema();
let validator =
jsonschema::validator_for(&emitted).expect("canonical output is self-contained");
assert!(validator.is_valid(&json!({"a": {"b": 1}})));
assert!(!validator.is_valid(&json!({"a": {"b": "s"}})));
assert!(!validator.is_valid(&json!({"a": {}})));
}
#[test]
fn absolute_self_reference_collapses_to_the_root_pointer() {
let canonical = canonicalize(&json!({
"$id": "https://example.com/root",
"type": "object",
"properties": {
"self": {"$ref": "https://example.com/root"}
}
}))
.expect("canonicalizes");
let emitted = canonical.to_json_schema();
assert_eq!(emitted["properties"]["self"], json!({"$ref": "#"}));
assert!(
canonical.definitions().next().is_none(),
"the root self-reference is not materialized as a definition"
);
}
#[test]
fn registry_already_holding_the_root_uri_still_canonicalizes() {
let schema = json!({
"$id": "https://example.com/root",
"$ref": "#/$defs/value",
"$defs": {"value": {"type": "string"}}
});
let registry = Registry::new()
.add("https://example.com/root", &schema)
.expect("resource URI is valid")
.prepare()
.expect("registry prepares");
let canonical = options()
.with_registry(®istry)
.canonicalize(&schema)
.expect("canonicalizes despite the pre-registered root");
assert_eq!(
canonical.view(),
CanonicalView::Reference("#/$defs/value".to_string())
);
let definitions: Vec<_> = canonical.definitions().collect();
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].0, "#/$defs/value");
assert_eq!(
canonical.to_json_schema()["$defs"]["value"],
json!({"type": "string"})
);
}
#[test]
fn a_definition_name_spelling_a_canonical_uri_does_not_alias_a_registry_resource() {
let remote = json!({"$id": "http://remote/a", "type": "string"});
let registry = Registry::new()
.add("http://remote/a", &remote)
.expect("resource URI is valid")
.prepare()
.expect("registry prepares");
let schema = json!({
"anyOf": [
{"$ref": "#/$defs/urn:jsonschema:canonical:http%253A%252F%252Fremote%252Fa"},
{"$ref": "http://remote/a"}
],
"$defs": {
"urn:jsonschema:canonical:http%3A%2F%2Fremote%2Fa": {
"type": "object",
"required": ["p"],
"properties": {
"p": {"$ref": "#/$defs/urn:jsonschema:canonical:http%253A%252F%252Fremote%252Fa"}
}
}
}
});
let canonical = options()
.with_registry(®istry)
.canonicalize(&schema)
.expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::Raw);
assert!(
canonical.is_satisfiable(),
"the string branch admits a value, so the document is not empty"
);
let validator = jsonschema::options()
.with_registry(®istry)
.build(&schema)
.expect("builds");
assert!(validator.is_valid(&json!("x")));
}
#[test]
fn unsupported_reference_target_keeps_the_whole_document_raw() {
let schema = json!({
"$ref": "#/$defs/value",
"$defs": {"value": {"if": {}, "unevaluatedProperties": false}}
});
let canonical = canonicalize(&schema).expect("canonicalizes");
assert!(matches!(canonical.view(), CanonicalView::Raw(_)));
assert_eq!(canonical.to_json_schema(), schema);
}
#[test]
fn symbolic_reference_operations_have_distinct_views() {
let intersection = canonicalize(&json!({
"allOf": [
{"$ref": "#/$defs/left"},
{"$ref": "#/$defs/right"}
],
"$defs": {
"left": {"type": "integer"},
"right": {"type": "string"}
}
}))
.expect("canonicalizes");
let CanonicalView::AllOf(branches) = intersection.view() else {
panic!("expected an AllOf view");
};
assert!(branches
.iter()
.all(|branch| matches!(branch.view(), CanonicalView::Reference(_))));
let complement = canonicalize(&json!({
"not": {"$ref": "#/$defs/other"},
"$defs": {"other": {"type": "integer"}}
}))
.expect("canonicalizes");
let CanonicalView::Not(inner) = complement.view() else {
panic!("expected a Not view");
};
assert!(matches!(inner.view(), CanonicalView::Reference(_)));
}
#[test]
fn string_view_exposes_facets() {
let CanonicalView::String(view) =
canonicalize(&json!({"type": "string", "minLength": 2, "pattern": "^a"}))
.unwrap()
.view()
else {
panic!("expected a String view");
};
assert_eq!(view.min_length, Some(Number::from(2u64)));
assert_eq!(view.max_length, None);
assert_eq!(view.patterns, vec!["^a".to_string()]);
}
#[test]
fn integer_view_exposes_bounds() {
let CanonicalView::Integer(view) =
canonicalize(&json!({"type": "integer", "minimum": 2, "maximum": 9}))
.unwrap()
.view()
else {
panic!("expected an Integer view");
};
assert_eq!(view.minimum, Some(Number::from(2)));
assert_eq!(view.maximum, Some(Number::from(9)));
}
#[test]
fn array_view_exposes_bounds() {
let CanonicalView::Array(view) =
canonicalize(&json!({"type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true}))
.unwrap()
.view()
else {
panic!("expected an Array view");
};
assert_eq!(view.min_items, Some(Number::from(1u64)));
assert_eq!(view.max_items, Some(Number::from(3u64)));
assert!(view.unique_items);
assert!(view.prefix_items.is_empty());
}
#[test]
fn array_view_exposes_prefix_items() {
let CanonicalView::Array(view) = canonicalize(&json!({
"type": "array",
"prefixItems": [{"type": "integer"}, {"type": "string"}],
"items": {"type": "boolean"}
}))
.unwrap()
.view() else {
panic!("expected an Array view");
};
let spellings: Vec<_> = view
.prefix_items
.iter()
.map(CanonicalSchema::to_json_schema)
.collect();
assert_eq!(
spellings,
vec![
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "integer"}),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string"})
]
);
assert_eq!(
view.items.unwrap().to_json_schema(),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "boolean"})
);
}
#[test]
fn object_view_exposes_bounds() {
let CanonicalView::Object(view) = canonicalize(
&json!({"type": "object", "minProperties": 1, "maxProperties": 3, "required": ["a"]}),
)
.unwrap()
.view() else {
panic!("expected an Object view");
};
assert_eq!(view.min_properties, None);
assert_eq!(view.max_properties, Some(Number::from(3u64)));
assert_eq!(view.required, vec!["a".to_string()]);
assert!(view.property_names.is_none());
assert!(view.properties.is_empty());
}
#[test]
fn object_view_exposes_properties() {
let CanonicalView::Object(view) =
canonicalize(&json!({"type": "object", "properties": {"a": {"type": "integer"}}}))
.unwrap()
.view()
else {
panic!("expected an Object view");
};
let schema = view.properties.get("a").expect("a property schema");
assert_eq!(
schema.to_json_schema(),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "integer"})
);
}
#[test]
fn object_view_exposes_property_names() {
let CanonicalView::Object(view) =
canonicalize(&json!({"type": "object", "propertyNames": {"maxLength": 4}}))
.unwrap()
.view()
else {
panic!("expected an Object view");
};
let names = view.property_names.expect("a key constraint");
assert_eq!(
names.to_json_schema(),
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"maxLength": 4
})
);
}
#[test_case(&json!({"type": "object", "propertyNames": {"format": "only-ok"}}), &json!({"nope": 1}); "key constraint")]
#[test_case(&json!({"type": "object", "properties": {"a": {"type": "string", "format": "only-ok"}}}), &json!({"a": "nope"}); "property schema")]
#[test_case(&json!({"type": "object", "properties": {"a": {"format": "only-ok"}}}), &json!({"a": "nope"}); "untyped property schema")]
#[test_case(&json!({"type": "object", "properties": {"a": {"type": "object", "propertyNames": {"format": "only-ok"}}}}), &json!({"a": {"nope": 1}}); "nested object")]
#[test_case(&json!({"type": "array", "items": {"type": "string", "format": "only-ok"}}), &json!(["nope"]); "item schema")]
#[test_case(&json!({"type": "array", "contains": {"type": "string", "format": "only-ok"}}), &json!(["nope"]); "contains schema")]
#[test_case(&json!({"type": "object", "properties": {"a": {"type": "array", "contains": {"type": "string", "format": "only-ok"}}}}), &json!({"a": ["nope"]}); "contains under a property")]
fn uncheckable_format_keeps_the_value_beside_the_leaf(leaf: &Value, instance: &Value) {
let schema = json!({"anyOf": [{"const": instance}, leaf]});
let canonical = options()
.should_validate_formats(true)
.canonicalize(&schema)
.expect("canonicalizes");
let build = |value: &Value| {
::jsonschema::options()
.with_format("only-ok", |text: &str| text == "ok")
.should_validate_formats(true)
.build(value)
.expect("builds")
};
assert!(build(&schema).is_valid(instance));
assert!(build(&canonical.to_json_schema()).is_valid(instance));
}
#[test]
fn uncheckable_format_scan_walks_a_typed_group() {
let instance = json!({"b": "nope"});
let schema = json!({"anyOf": [
{"enum": [instance]},
{"type": "object", "properties": {
"a": {"type": "integer", "enum": [1, 2]},
"b": {"type": "string", "format": "only-ok"}
}}
]});
let canonical = options()
.with_draft(Draft::Draft4)
.should_validate_formats(true)
.canonicalize(&schema)
.expect("canonicalizes");
let build = |value: &Value| {
::jsonschema::options()
.with_draft(Draft::Draft4)
.with_format("only-ok", |text: &str| text == "ok")
.should_validate_formats(true)
.build(value)
.expect("builds")
};
assert!(build(&schema).is_valid(&instance));
assert!(build(&canonical.to_json_schema()).is_valid(&instance));
}
#[test]
fn unmodeled_documents_hash_by_document_identity() {
let canonical = |text: &str| {
canonicalize(&serde_json::from_str::<Value>(text).expect("valid schema JSON"))
.expect("canonicalizes")
};
let integer = canonical(
r#"{"if": {}, "unevaluatedProperties": {"enum": [1, null, true, "x", [2], {"b": 3}]}}"#,
);
let float = canonical(
r#"{"if": {}, "unevaluatedProperties": {"enum": [1.0, null, true, "x", [2], {"b": 3}]}}"#,
);
assert_eq!(integer.kind(), CanonicalKind::Raw);
let distinct: HashSet<CanonicalSchema> =
[integer.clone(), float, integer].into_iter().collect();
assert_eq!(distinct.len(), 2);
}
#[test]
fn number_view_exposes_bounds() {
let CanonicalView::Number(view) = canonicalize(&json!({
"type": "number",
"exclusiveMinimum": 1.5,
"maximum": 9.5
}))
.unwrap()
.view() else {
panic!("expected a Number view");
};
assert_eq!(view.minimum, Number::from_f64(1.5));
assert!(view.exclusive_minimum);
assert_eq!(view.maximum, Number::from_f64(9.5));
assert!(!view.exclusive_maximum);
}
#[cfg(feature = "arbitrary-precision")]
#[test_case(r#"{"type": "string", "minLength": 99999999999999999999999}"#, CanonicalKind::String, "minLength", "99999999999999999999999"; "length bound")]
#[test_case(r#"{"type": "integer", "minimum": 99999999999999999999999}"#, CanonicalKind::Integer, "minimum", "99999999999999999999999"; "integer bound")]
#[test_case(r#"{"type": "array", "minItems": 99999999999999999999999}"#, CanonicalKind::Array, "minItems", "99999999999999999999999"; "array length bound")]
#[test_case(r#"{"type": "object", "minProperties": 99999999999999999999999}"#, CanonicalKind::Object, "minProperties", "99999999999999999999999"; "object size bound")]
#[test_case(r#"{"type": "integer", "maximum": 18446744073709551615}"#, CanonicalKind::Integer, "maximum", "18446744073709551615"; "integer maximum at u64 max")]
#[test_case(r#"{"type": "number", "maximum": 18446744073709551615}"#, CanonicalKind::Number, "maximum", "18446744073709551615"; "number maximum at u64 max")]
#[test_case(r#"{"type": "integer", "minimum": -99999999999999999999999}"#, CanonicalKind::Integer, "minimum", "-99999999999999999999999"; "integer minimum below negative i64")]
fn past_range_bound_round_trips(text: &str, kind: CanonicalKind, keyword: &str, bound: &str) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let canonical = canonicalize(&schema).expect("canonicalizes");
assert_eq!(canonical.kind(), kind);
assert_eq!(canonical.to_json_schema()[keyword].to_string(), bound);
}
#[cfg(feature = "arbitrary-precision")]
#[test_case(r#"{"maximum": 18446744073709551615}"#, "maximum", "18446744073709551615"; "untyped maximum at u64 max")]
#[test_case(r#"{"minimum": -18446744073709551615}"#, "minimum", "-18446744073709551615"; "untyped minimum below negative i64")]
#[test_case(r#"{"maximum": -99999999999999999999999}"#, "maximum", "-99999999999999999999999"; "untyped maximum below negative i64")]
fn untyped_past_range_numeric_bound_round_trips(text: &str, keyword: &str, bound: &str) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let canonical = canonicalize(&schema).expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::AnyOf);
let emitted = canonical.to_json_schema();
let branches = emitted["anyOf"].as_array().expect("anyOf branches");
let number_branch = branches
.iter()
.find(|branch| branch["type"] == json!("number"))
.expect("number branch");
assert_eq!(number_branch[keyword].to_string(), bound);
}
#[cfg(feature = "arbitrary-precision")]
#[test_case(r#"{"type": "number", "maximum": 1e400, "exclusiveMaximum": 0.1}"#, "exclusiveMaximum", "0.1"; "maximum past f64 range")]
#[test_case(r#"{"type": "number", "minimum": -1e400, "exclusiveMinimum": 0.1}"#, "exclusiveMinimum", "0.1"; "minimum past f64 range")]
fn past_range_bound_keeps_its_tighter_partner(text: &str, keyword: &str, bound: &str) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let emitted = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
assert_eq!(emitted[keyword].to_string(), bound, "{emitted}");
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case("string", "minLength")]
#[test_case("string", "maxLength")]
#[test_case("array", "minItems")]
#[test_case("array", "maxItems")]
#[test_case("object", "minProperties")]
#[test_case("object", "maxProperties")]
fn huge_count_bound_stays_raw(ty: &str, keyword: &str) {
let schema: Value = serde_json::from_str(&format!(
r#"{{"type": "{ty}", "{keyword}": 99999999999999999999999}}"#
))
.unwrap();
assert_eq!(canonicalize(&schema).unwrap().kind(), CanonicalKind::Raw);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(r#"{"type": "integer", "minimum": 99999999999999999999999}"#; "integer minimum")]
#[test_case(r#"{"type": "integer", "maximum": 99999999999999999999999}"#; "integer maximum")]
#[test_case(r#"{"type": "number", "minimum": 99999999999999999999999}"#; "number minimum")]
#[test_case(r#"{"type": "number", "maximum": 99999999999999999999999}"#; "number maximum")]
#[test_case(r#"{"allOf": [{"type": "integer"}, {"minimum": 99999999999999999999999}]}"#; "interval meeting integer")]
#[test_case(r#"{"maximum": 18446744073709551615}"#; "untyped maximum at u64 max")]
#[test_case(r#"{"minimum": -18446744073709551615}"#; "untyped minimum below negative i64")]
#[test_case(r#"{"maximum": -99999999999999999999999}"#; "untyped maximum below negative i64")]
#[test_case(r#"{"type": "integer", "maximum": 18446744073709551615}"#; "integer maximum at u64 max")]
#[test_case(r#"{"type": "number", "maximum": 18446744073709551615}"#; "number maximum at u64 max")]
#[test_case(r#"{"type": "integer", "minimum": -99999999999999999999999}"#; "integer minimum below negative i64")]
fn huge_numeric_bound_stays_raw(text: &str) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
assert_eq!(canonicalize(&schema).unwrap().kind(), CanonicalKind::Raw);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(r#"{"maximum": 9223372036854775807}"#, false; "maximum at i64 max is modeled")]
#[test_case(r#"{"maximum": 9223372036854775808}"#, true; "maximum one past i64 max stays raw")]
#[test_case(r#"{"minimum": -9223372036854775808}"#, false; "minimum at i64 min is modeled")]
#[test_case(r#"{"minimum": -9223372036854775809}"#, false; "minimum one past i64 min rounds to i64 min")]
#[test_case(r#"{"minimum": -9223372036854777856}"#, true; "minimum at first float below i64 min stays raw")]
fn representable_range_boundary(text: &str, stays_raw: bool) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let is_raw = canonicalize(&schema).unwrap().kind() == CanonicalKind::Raw;
assert_eq!(is_raw, stays_raw);
}
#[test]
fn pattern_engine_selects_dialect() {
let schema = json!({"pattern": "^(?!x)"});
assert!(canonicalize(&schema).is_ok());
let error = options()
.with_pattern_options(PatternOptions::regex())
.canonicalize(&schema)
.unwrap_err();
assert!(matches!(
error,
CanonicalizationError::InvalidPattern { .. }
));
}
#[test_case(&json!(42), "schema must be a boolean or object, got: 42"; "invalid schema type")]
#[test_case(&json!({"pattern": "["}), "invalid regular expression: \"[\""; "invalid pattern")]
fn error_display(schema: &Value, message: &str) {
assert_eq!(canonicalize(schema).unwrap_err().to_string(), message);
}
#[test_case(&json!({"type": "string"}), CanonicalKind::MultiType, "multi_type"; "multi_type")]
#[test_case(&json!({"const": 1}), CanonicalKind::Const, "const"; "const_value")]
#[test_case(&json!({"enum": [1, 2, 3]}), CanonicalKind::Enum, "enum"; "enum_values")]
#[test_case(&json!({}), CanonicalKind::True, "true"; "empty object")]
#[test_case(&json!(false), CanonicalKind::False, "false"; "boolean false")]
#[test_case(&json!({"type": "integer", "minimum": 0}), CanonicalKind::Integer, "integer"; "integer_leaf")]
#[test_case(&json!({"type": "number", "minimum": 0}), CanonicalKind::Number, "number"; "number_leaf")]
#[test_case(&json!({"if": {}, "unevaluatedProperties": false}), CanonicalKind::Raw, "raw"; "raw")]
fn kind_reports_its_label(schema: &Value, kind: CanonicalKind, label: &str) {
let canonical = canonicalize(schema).expect("canonicalizes");
assert_eq!(canonical.kind(), kind);
assert_eq!(canonical.kind().as_str(), label);
}
#[test_case(&json!({"type": ["string", "number"]}), &CanonicalView::MultiType(JsonType::String | JsonType::Number); "multi_type")]
#[test_case(&json!({"const": [1, "a"]}), &CanonicalView::Const(json!([1, "a"])); "const_value")]
#[test_case(&json!({"enum": [3, 1, 2]}), &CanonicalView::Enum(vec![json!(1), json!(2), json!(3)]); "enum_values")]
#[test_case(&json!(true), &CanonicalView::True; "boolean true")]
#[test_case(&json!({}), &CanonicalView::True; "empty object")]
#[test_case(&json!(false), &CanonicalView::False; "boolean false")]
fn view_matches_expected(schema: &Value, expected: &CanonicalView) {
assert_eq!(&canonicalize(schema).unwrap().view(), expected);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(r#"{"type":"integer","enum":[1,2,10000000000000000000],"minimum":2}"#, &json!({"enum":[2,10_000_000_000_000_000_000_u64]}); "minimum keeps oversized")]
#[test_case(r#"{"type":"integer","enum":[1,2,10000000000000000000],"maximum":5}"#, &json!({"enum":[1,2]}); "maximum drops oversized")]
#[test_case(r#"{"allOf":[{"type":"integer","minimum":2},{"enum":[1,2,10000000000000000000]}]}"#, &json!({"enum":[2,10_000_000_000_000_000_000_u64]}); "cross-branch minimum keeps oversized")]
#[test_case(r#"{"type":"integer","enum":[1,2,-10000000000000000000],"maximum":5}"#, &json!({"enum":[1,2,-1e19]}); "maximum keeps undersized")]
#[test_case(r#"{"type":"integer","enum":[1,2,-10000000000000000000],"minimum":0}"#, &json!({"enum":[1,2]}); "minimum drops undersized")]
fn oversized_integer_compares_by_overflow_direction(text: &str, expected: &Value) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let canonical = canonicalize(&schema).expect("canonicalizes");
let mut expected = expected.as_object().expect("object").clone();
expected.insert(
"$schema".into(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
assert_eq!(canonical.to_json_schema(), Value::Object(expected));
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(r#"{"anyOf":[{"type":"integer","minimum":2},{"const":1e30}]}"#, CanonicalKind::Integer; "absorbed above every maximum")]
#[test_case(r#"{"anyOf":[{"type":"integer","maximum":5},{"const":1e30}]}"#, CanonicalKind::AnyOf; "kept beyond the maximum")]
fn oversized_member_absorption(text: &str, kind: CanonicalKind) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
assert_eq!(canonicalize(&schema).expect("canonicalizes").kind(), kind);
}
#[test_case(&json!({"type": "integer", "enum": [1, 2, 3], "minimum": 2}), &json!({"type": "integer", "enum": [2, 3]}); "narrows to survivors")]
#[test_case(&json!({"type": "integer", "enum": [1, 2, 3], "minimum": 5}), &json!({"not": {}}); "bound excludes all")]
#[test_case(&json!({"allOf": [{"type": ["string", "integer"]}, {"enum": ["a", 1]}]}), &json!({"anyOf": [{"type": "integer", "enum": [1]}, {"enum": ["a"]}]}); "mixed type set guards only integers")]
#[test_case(&json!({"allOf": [{"type": "integer", "minimum": 2}, {"type": "integer", "enum": [1, 2, 3]}]}), &json!({"type": "integer", "enum": [2, 3]}); "bound before typed group")]
fn draft4_integer_typed_group_intersects_bound(schema: &Value, expected: &Value) {
let canonical = options()
.with_draft(Draft::Draft4)
.canonicalize(schema)
.expect("canonicalizes");
let mut expected = expected.as_object().expect("object").clone();
expected.insert(
"$schema".into(),
json!("http://json-schema.org/draft-04/schema#"),
);
assert_eq!(canonical.to_json_schema(), Value::Object(expected));
}
#[test_case(&json!({"type": "integer", "enum": [1, 2, 3]}); "same object")]
#[test_case(&json!({"allOf": [{"enum": [1, 2, 3]}, {"type": "integer", "minimum": 2}]}); "value set meets a bound")]
fn draft4_integer_values_are_a_typed_group(schema: &Value) {
let canonical = options()
.with_draft(Draft::Draft4)
.canonicalize(schema)
.expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::TypedGroup);
assert_eq!(canonical.kind().as_str(), "typed_group");
let CanonicalView::TypedGroup(group) = canonical.view() else {
panic!("expected a TypedGroup view");
};
assert_eq!(group.ty, JsonType::Integer);
assert_eq!(group.body.kind(), CanonicalKind::Enum);
}
#[test]
fn view_exposes_anyof_branches() {
let canonical =
canonicalize(&json!({"anyOf": [{"type": "string"}, {"const": 1}]})).expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::AnyOf);
assert_eq!(canonical.kind().as_str(), "any_of");
let CanonicalView::AnyOf(branches) = canonical.view() else {
panic!("expected an AnyOf view");
};
assert_eq!(
branches
.iter()
.map(CanonicalSchema::view)
.collect::<Vec<_>>(),
vec![
CanonicalView::MultiType(JsonType::String.into()),
CanonicalView::Const(json!(1)),
]
);
}
#[test]
fn validation_error_display_and_source() {
let error = canonicalize(&json!({"type": 123})).expect_err("invalid schema must error");
assert!(error.to_string().starts_with("schema validation failed:"));
assert!(std::error::Error::source(&error).is_some());
}
#[test]
fn deeply_nested_document_round_trips() {
let mut schema = json!({"type": "string"});
for _ in 0..300 {
let mut map = Map::new();
map.insert("if".to_string(), json!({}));
map.insert("unevaluatedProperties".to_string(), schema);
schema = Value::Object(map);
}
let canonical = canonicalize(&schema).expect("canonicalizes");
assert_eq!(canonical.to_json_schema(), schema);
}
#[test]
fn negated_type_set_complement_converges_with_direct_spelling() {
let negated =
canonicalize(&json!({"not": {"type": ["boolean", "number", "string", "array", "object"]}}))
.unwrap();
assert_eq!(negated, canonicalize(&json!({"type": "null"})).unwrap());
let negated =
canonicalize(&json!({"not": {"type": ["null", "number", "string", "array", "object"]}}))
.unwrap();
assert_eq!(negated, canonicalize(&json!({"type": "boolean"})).unwrap());
}
#[cfg(feature = "arbitrary-precision")]
#[test_case(r#"{"const":1e999999999999999999999}"#; "huge_exponent_const")]
#[test_case(r#"{"enum":[1e999999999999999999999]}"#; "huge_exponent_enum")]
#[test_case(r#"{"type":"number","minimum":1e999999999999999999999}"#; "huge_exponent_bound")]
#[test_case(r#"{"type":"number","multipleOf":1e999999999999999999999}"#; "huge_exponent_divisor")]
#[test_case(&format!(r#"{{"const":1{}}}"#, "0".repeat((1 << 20) + 1)); "huge_digit_count")]
fn numerals_without_exact_comparison_stay_raw(text: &str) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let canonical = canonicalize(&schema).expect("canonicalizes");
assert!(matches!(canonical.view(), CanonicalView::Raw(_)));
assert_eq!(canonical.to_json_schema(), schema);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(&json!({"type": "array", "contains": {"type": "null"}, "minContains": 1e100}); "minimum past u64")]
#[test_case(&json!({"type": "array", "contains": {"type": "null"}, "maxContains": 1e100}); "maximum past u64")]
fn contains_counts_without_modeled_form_stay_raw(schema: &Value) {
let canonical = canonicalize(schema).expect("canonicalizes");
assert!(matches!(canonical.view(), CanonicalView::Raw(_)));
assert_eq!(&canonical.to_json_schema(), schema);
}
#[cfg(feature = "arbitrary-precision")]
#[test_case("string", "minLength"; "minimum string length past the expansion cap")]
#[test_case("string", "maxLength"; "maximum string length past the expansion cap")]
#[test_case("array", "minItems"; "minimum array length past the expansion cap")]
#[test_case("array", "maxItems"; "maximum array length past the expansion cap")]
#[test_case("object", "minProperties"; "minimum object size past the expansion cap")]
#[test_case("object", "maxProperties"; "maximum object size past the expansion cap")]
#[test_case("array", "minContains"; "minimum contains count past the expansion cap")]
#[test_case("array", "maxContains"; "maximum contains count past the expansion cap")]
fn count_bound_without_modeled_form_stays_raw(ty: &str, keyword: &str) {
let count = format!("1{}e1000000", "0".repeat(48_577));
let contains = keyword
.ends_with("Contains")
.then_some(r#","contains":{"type":"null"}"#);
let text = format!(
r#"{{"type":"{ty}"{},"{keyword}":{count}}}"#,
contains.unwrap_or_default()
);
let schema: Value = serde_json::from_str(&text).expect("valid schema JSON");
let canonical = canonicalize(&schema).expect("canonicalizes");
assert!(matches!(canonical.view(), CanonicalView::Raw(_)));
assert_eq!(canonical.to_json_schema(), schema);
}
#[test]
fn const_identity_is_value_identity() {
let integer = canonicalize(&json!({"const": 1})).unwrap();
let float = canonicalize(&json!({"const": 1.0})).unwrap();
assert_eq!(integer, float);
assert_ne!(integer, canonicalize(&json!({"const": "1"})).unwrap());
assert_eq!(
integer.to_json_schema(),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "const": 1})
);
}
#[test_case(&json!(5.0), &json!(5); "positive")]
#[test_case(&json!(-5.0), &json!(-5); "negative")]
fn integer_valued_float_const_folds_to_integer(float: &Value, integer: &Value) {
let from_float = canonicalize(&json!({ "const": float })).unwrap();
let from_integer = canonicalize(&json!({ "const": integer })).unwrap();
assert_eq!(from_float, from_integer);
assert_eq!(
from_float.to_json_schema(),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "const": integer})
);
}
#[test_case(&json!({"enum": [null, false, true]}), &json!({"type": ["null", "boolean"]}); "saturates null and boolean")]
#[test_case(&json!({"enum": [false, true]}), &json!({"type": "boolean"}); "saturates boolean")]
#[test_case(&json!({"enum": [null, false]}), &json!({"enum": [null, false]}); "partial set stays enum")]
fn finite_value_set_saturation(schema: &Value, expected: &Value) {
let canonical = canonicalize(schema).expect("canonicalizes");
let mut expected = expected.as_object().expect("object").clone();
expected.insert(
"$schema".into(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
assert_eq!(canonical.to_json_schema(), Value::Object(expected));
}
#[test]
fn const_intersects_enum() {
let canonical = canonicalize(&json!({"enum": [1, 2, 3], "const": 2})).expect("canonicalizes");
assert_eq!(
canonical.to_json_schema(),
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "const": 2})
);
}
#[test]
fn canonical_schema_ordering() {
let one = canonicalize(&json!({"const": 1})).unwrap();
let two = canonicalize(&json!({"const": 2})).unwrap();
assert_eq!(one.cmp(&one), Ordering::Equal);
assert!(one < two);
assert!(two > one);
let raw = |text: &str| canonicalize(&serde_json::from_str(text).unwrap()).unwrap();
let raw_one = raw(r#"{"if":{},"unevaluatedProperties":{"const":1}}"#);
let raw_two = raw(r#"{"if":{},"unevaluatedProperties":{"const":2}}"#);
assert_eq!(raw_one.partial_cmp(&raw_two), Some(Ordering::Less));
assert!(raw_one < raw_two);
#[cfg(feature = "arbitrary-precision")]
assert!(
raw(r#"{"if":{},"unevaluatedProperties":{"const":1e400}}"#)
< raw(r#"{"if":{},"unevaluatedProperties":{"const":2e400}}"#)
);
}
#[test_case(Draft::Draft6, "http://json-schema.org/draft-06/schema#"; "draft6")]
#[test_case(Draft::Draft201909, "https://json-schema.org/draft/2019-09/schema"; "draft2019-09")]
fn draft_stamps_its_schema_uri(draft: Draft, uri: &str) {
let canonical = options()
.with_draft(draft)
.canonicalize(&json!({"type": "string"}))
.expect("canonicalizes");
assert_eq!(
canonical.to_json_schema(),
json!({"$schema": uri, "type": "string"})
);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(
r#"{"type": "integer", "multipleOf": 9007199254740993}"#,
&json!({"type": "integer", "multipleOf": 9_007_199_254_740_993_u64});
"a divisor no decimal spells is kept as written"
)]
#[test_case(
r#"{"type": "integer", "multipleOf": 4611686018427387903}"#,
&json!({"type": "integer", "multipleOf": 4_611_686_018_427_387_903_u64});
"a divisor past f64 precision is kept as written"
)]
#[test_case(
r#"{"type": "integer", "multipleOf": 1e30}"#,
&json!({"type": "integer", "multipleOf": 1e30});
"a divisor past the integer range is kept as written"
)]
#[test_case(
r#"{"allOf":[{"type":"integer","multipleOf":9007199254740992},{"type":"integer","multipleOf":9007199254740991}]}"#,
&json!({"type": "integer", "allOf": [{"multipleOf": 9_007_199_254_740_991_u64}, {"multipleOf": 9_007_199_254_740_992_u64}]});
"divisors with no exact common multiple stay apart"
)]
#[test_case(
r#"{"allOf":[{"type":"integer","multipleOf":9007199254740992,"minimum":1},{"type":"integer","multipleOf":9007199254740991}]}"#,
&json!({"type": "integer", "minimum": 9_007_199_254_740_992_u64, "allOf": [{"multipleOf": 9_007_199_254_740_991_u64}, {"multipleOf": 9_007_199_254_740_992_u64}]});
"divisors with no exact common multiple keep a snapped bound"
)]
#[test_case(
r#"{"allOf":[{"type":"number","multipleOf":3},{"type":"number","multipleOf":3002399751580331}]}"#,
&json!({"type": "integer", "allOf": [{"multipleOf": 3}, {"multipleOf": 3_002_399_751_580_331_u64}]});
"divisors whose common multiple no decimal spells stay apart"
)]
fn divisors_past_exact_precision(text: &str, expected: &Value) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let mut form = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
form.as_object_mut().expect("object").remove("$schema");
assert_eq!(&form, expected);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test]
fn divisor_keeps_member_past_representable_range() {
let schema = json!({"allOf": [{"type": "integer", "multipleOf": 2}, {"const": 1e30}]});
let mut form = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
form.as_object_mut().expect("object").remove("$schema");
assert_eq!(form, json!({"const": 1e30}));
}
#[test_case("2")]
#[test_case("3")]
#[test_case("1")]
#[test_case("0.5")]
#[test_case("0.75")]
#[test_case("1.5")]
#[test_case("0.123456789")]
#[test_case("9007199254740992")]
#[test_case("9007199254740993")]
#[test_case("4503599627370496")]
#[test_case("1e300")]
#[test_case("1e-7")]
fn divisor_oracle_matches_the_validator(divisor: &str) {
const INSTANCES: &[&str] = &[
"0",
"1",
"2",
"3",
"6",
"-4",
"1.5",
"2.5",
"0.25",
"9007199254740993",
"12345678900000001",
"27021597764222977",
"1e30",
"-9007199254740993",
];
let divisor: serde_json::Number = divisor.parse().expect("divisor");
let validator = jsonschema::validator_for(&json!({"multipleOf": divisor})).expect("compiles");
for instance in INSTANCES {
let instance: serde_json::Number = instance.parse().expect("instance");
assert_eq!(
jsonschema_value::numeric_check::satisfies_multiple_of(&divisor, &instance),
validator.is_valid(&Value::Number(instance.clone())),
"multipleOf {divisor} on {instance}"
);
}
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test]
fn divisor_no_decimal_spells_is_modeled() {
let schema = json!({"type": "number", "multipleOf": 9_007_199_254_740_993_u64});
let canonical = canonicalize(&schema).expect("canonicalizes");
assert_ne!(canonical.kind(), jsonschema::canonical::CanonicalKind::Raw);
let mut form = canonical.to_json_schema();
form.as_object_mut().expect("object").remove("$schema");
assert_eq!(
form,
json!({"type": "integer", "multipleOf": 9_007_199_254_740_993_u64})
);
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test_case(
r#"{"type":"number","minimum":9223372036854775807,"multipleOf":1}"#,
&["9223372036854775807", "9223372036854775808"];
"a bound with no representable multiple"
)]
#[test_case(
r#"{"type":"number","minimum":-4,"maximum":9223372036854775807,"multipleOf":0.5}"#,
&["9223372036854775808", "-4", "0.5"];
"an upper end past exact precision"
)]
#[test_case(
r#"{"type":"number","exclusiveMinimum":9007199254740992,"multipleOf":0.5}"#,
&["9007199254740992", "9007199254740993"];
"an excluded end past exact precision"
)]
#[test_case(
r#"{"type":"integer","minimum":9223372036854775807,"multipleOf":2}"#,
&["9223372036854775808", "1e30", "9223372036854775807"];
"an integer bound with no representable multiple"
)]
fn wide_bounds_keep_validation(text: &str, instances: &[&str]) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let emitted = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
for instance in instances {
let instance: Value = serde_json::from_str(instance).expect("instance");
assert_eq!(
jsonschema::is_valid(&schema, &instance),
jsonschema::is_valid(&emitted, &instance),
"{instance} against {emitted}"
);
}
}
#[cfg(not(feature = "arbitrary-precision"))]
#[test]
fn identity_divisor_drops_beside_a_whole_one() {
let schema = json!({"allOf": [
{"type": "number", "multipleOf": 2},
{"type": "number", "minimum": 0, "multipleOf": 1e30}
]});
let mut form = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
form.as_object_mut().expect("object").remove("$schema");
assert_eq!(
form,
json!({"type": "integer", "minimum": 0, "multipleOf": 1e30})
);
}
#[cfg(feature = "arbitrary-precision")]
#[test_case(
r#"{"allOf":[{"type":"number","multipleOf":3},{"type":"number","multipleOf":1.5}]}"#,
&json!({"type": "integer", "multipleOf": 3});
"a whole divisor stands for a fractional one it covers"
)]
#[test_case(
r#"{"allOf":[{"type":"number","multipleOf":2},{"type":"number","multipleOf":2.5}]}"#,
&json!({"type": "integer", "multipleOf": 10});
"unlike divisors fold to their common multiple"
)]
fn unlike_divisors_fold_under_arbitrary_precision(text: &str, expected: &Value) {
let schema: Value = serde_json::from_str(text).expect("valid schema JSON");
let mut form = canonicalize(&schema)
.expect("canonicalizes")
.to_json_schema();
form.as_object_mut().expect("object").remove("$schema");
assert_eq!(&form, expected);
}
macro_rules! bundled_metaschemas {
($($path:literal),* $(,)?) => {
&[$(($path, include_str!(concat!("../../jsonschema-referencing/metaschemas/", $path)))),*]
};
}
const BUNDLED_METASCHEMAS: &[(&str, &str)] = bundled_metaschemas![
"draft4.json",
"draft6.json",
"draft7.json",
"draft2019-09/schema.json",
"draft2019-09/meta/applicator.json",
"draft2019-09/meta/content.json",
"draft2019-09/meta/core.json",
"draft2019-09/meta/format.json",
"draft2019-09/meta/meta-data.json",
"draft2019-09/meta/validation.json",
"draft2020-12/schema.json",
"draft2020-12/meta/applicator.json",
"draft2020-12/meta/content.json",
"draft2020-12/meta/core.json",
"draft2020-12/meta/format-annotation.json",
"draft2020-12/meta/format-assertion.json",
"draft2020-12/meta/meta-data.json",
"draft2020-12/meta/unevaluated.json",
"draft2020-12/meta/validation.json",
];
#[test]
fn bundled_metaschemas_are_modeled() {
let mut raw = Vec::new();
for (name, text) in BUNDLED_METASCHEMAS {
let schema: Value = serde_json::from_str(text).expect("a bundled metaschema is valid JSON");
match options().canonicalize(&schema) {
Ok(canonical) if canonical.kind() == CanonicalKind::Raw => raw.push(*name),
Ok(_) => {}
Err(error) => panic!("{name}: {error}"),
}
}
assert!(raw.is_empty(), "these metaschemas stayed raw: {raw:#?}");
}
#[test_case(&json!({"type": 5}); "type is not a name")]
#[test_case(&json!({"if": 5}); "subschema is not a schema")]
#[test_case(&json!({"dependencies": {"a": 5}}); "dependency is neither schema nor name list")]
#[test_case(&json!({"dependentRequired": {"a": ["x", 1]}}); "dependent requirement is not a name")]
#[test_case(&json!({"dependentSchemas": {"a": 5}}); "dependent schema is not a schema")]
#[test_case(&json!({"items": [true]}); "2020-12 items is not a tuple")]
#[test_case(
&json!({"contains": 5, "unevaluatedItems": false});
"contains beside unevaluated items is not a schema"
)]
#[test_case(
&json!({"allOf": [5], "unevaluatedProperties": false});
"property cover branch is not a schema"
)]
#[test_case(
&json!({"allOf": [5], "unevaluatedItems": false});
"item cover branch is not a schema"
)]
#[test_case(
&json!({"allOf": [{"properties": {"a": true}}], "properties": 5, "unevaluatedProperties": false});
"hoisted properties is not an object"
)]
#[test_case(
&json!({"allOf": [{"prefixItems": [true]}], "prefixItems": 5, "unevaluatedItems": false});
"padded tuple is not an array"
)]
#[test_case(
&json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "integer"});
"target declares another draft"
)]
fn unvalidated_registry_target_keeps_the_document_raw(target: &Value) {
let registry = Registry::new()
.add("https://example.com/target", target)
.expect("resource URI is valid")
.prepare()
.expect("registry prepares");
let document = json!({"$ref": "https://example.com/target"});
let canonical = options()
.with_draft(Draft::Draft202012)
.with_registry(®istry)
.canonicalize(&document)
.expect("canonicalizes");
assert_eq!(canonical.kind(), CanonicalKind::Raw);
assert_eq!(canonical.to_json_schema(), document);
}
#[test]
fn a_resolution_error_carries_its_cause() {
let error = canonicalize(&json!({"$ref": "#/$defs/%FF", "$defs": {"a": true}}))
.expect_err("the pointer does not decode");
assert!(error.to_string().contains("valid UTF-8"), "{error}");
assert!(std::error::Error::source(&error).is_some());
}
#[test]
fn an_invalid_schema_type_error_has_no_cause() {
let error = canonicalize(&json!([])).expect_err("an array is not a schema");
assert!(std::error::Error::source(&error).is_none());
}
#[test]
fn definition_looks_up_one_target() {
let schema = canonicalize(&json!({
"$defs": {"A": {"type": "string"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
let (uri, expected) = schema.definitions().next().expect("one definition");
assert_eq!(schema.definition(&uri), Some(expected));
assert_eq!(schema.definition("#/$defs/absent"), None);
}
#[test]
fn hash_ignores_the_definition_map() {
fn digest(schema: &jsonschema::canonical::CanonicalSchema) -> u64 {
let mut hasher = DefaultHasher::new();
schema.hash(&mut hasher);
hasher.finish()
}
let string_target = canonicalize(&json!({
"$id": "https://example.com/s",
"$defs": {"A": {"type": "string"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
let integer_target = canonicalize(&json!({
"$id": "https://example.com/s",
"$defs": {"A": {"type": "integer"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
assert_ne!(string_target, integer_target);
assert_eq!(digest(&string_target), digest(&integer_target));
}
#[test_case(&json!({"type": "string"}), &json!({"minLength": 4}), &json!({"type": "string", "minLength": 4}); "bound folds into the string leaf")]
#[test_case(&json!({"const": "A"}), &json!({"pattern": "^A$"}), &json!({"const": "A"}); "matching pattern keeps the constant")]
#[test_case(&json!({"const": "A"}), &json!({"const": "B"}), &json!({"not": {}}); "disjoint constants fold to false")]
fn intersect_folds(left: &Value, right: &Value, expected: &Value) {
let left = canonicalize(left).expect("canonicalizes");
let right = canonicalize(right).expect("canonicalizes");
let mut expected = expected.as_object().expect("object").clone();
expected.insert(
"$schema".into(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
assert_eq!(
left.intersect(&right).expect("intersects").to_json_schema(),
Value::Object(expected)
);
}
fn unmodeled() -> Value {
json!({"if": {}, "unevaluatedProperties": false})
}
#[test]
fn intersect_rejects_a_raw_root() {
let raw = canonicalize(&unmodeled()).expect("canonicalizes");
let modeled = canonicalize(&json!({"type": "string"})).expect("canonicalizes");
let error = raw.intersect(&modeled).expect_err("the left side is raw");
assert!(matches!(error, CanonicalizationError::UnmodeledOperand));
assert_eq!(
error.to_string(),
"operand is not modeled in canonical form"
);
assert!(matches!(
modeled.intersect(&raw),
Err(CanonicalizationError::UnmodeledOperand)
));
}
#[test]
fn intersect_rejects_a_raw_reference_target() {
let document = json!({
"properties": {"a": {"$ref": "#/$defs/A"}},
"$defs": {"A": unmodeled()}
});
let raw = canonicalize(&document).expect("canonicalizes");
assert_eq!(raw.kind(), CanonicalKind::Raw);
assert_eq!(raw.definitions().next(), None);
let modeled = canonicalize(&json!({"type": "string"})).expect("canonicalizes");
assert!(matches!(
raw.intersect(&modeled),
Err(CanonicalizationError::UnmodeledOperand)
));
}
#[test]
fn intersect_keeps_references_resolvable_within_one_document() {
let root = canonicalize(&json!({
"type": "object",
"$defs": {"A": {"type": "string"}, "B": {"minLength": 2}},
"properties": {"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/B"}}
}))
.expect("canonicalizes");
let CanonicalView::Object(view) = root.view() else {
panic!("expected an Object view");
};
let left = view.properties.get("a").expect("property a").clone();
let right = view.properties.get("b").expect("property b").clone();
let merged = left.intersect(&right).expect("intersects");
assert!(merged.definition("#/$defs/A").is_some());
assert!(merged.definition("#/$defs/B").is_some());
}
#[test]
fn intersect_rejects_operands_from_different_drafts() {
let draft7 = options()
.with_draft(Draft::Draft7)
.canonicalize(&json!({"type": "string"}))
.expect("canonicalizes");
let latest = canonicalize(&json!({"type": "string"})).expect("canonicalizes");
let error = draft7.intersect(&latest).expect_err("the drafts differ");
assert!(matches!(
error,
CanonicalizationError::IncompatibleOperands(OperandMismatch::Drafts {
left: Draft::Draft7,
right: Draft::Draft202012
})
));
assert_eq!(
error.to_string(),
"operands canonicalized under Draft7 and Draft202012"
);
}
#[test]
fn intersect_rejects_operands_with_different_format_assertion_policy() {
let asserting = options()
.should_validate_formats(true)
.canonicalize(&json!({"type": "string"}))
.expect("canonicalizes");
let annotating = canonicalize(&json!({"type": "string"})).expect("canonicalizes");
let error = asserting
.intersect(&annotating)
.expect_err("the format policies differ");
assert!(matches!(
error,
CanonicalizationError::IncompatibleOperands(OperandMismatch::FormatAssertions)
));
assert_eq!(
error.to_string(),
"operands disagree on whether `format` asserts"
);
}
#[test]
fn intersect_rejects_operands_with_different_pattern_engines() {
let regex_engine = options()
.with_pattern_options(PatternOptions::regex())
.canonicalize(&json!({"type": "string"}))
.expect("canonicalizes");
let fancy_engine = canonicalize(&json!({"type": "string"})).expect("canonicalizes");
let error = regex_engine
.intersect(&fancy_engine)
.expect_err("the pattern engines differ");
assert!(matches!(
error,
CanonicalizationError::IncompatibleOperands(OperandMismatch::PatternEngine)
));
assert_eq!(
error.to_string(),
"operands canonicalized with different pattern engines"
);
}
#[test_case(false; "empty map on the right")]
#[test_case(true; "empty map on the left")]
fn intersect_adopts_the_only_definition_map(swap: bool) {
let referencing = canonicalize(&json!({
"$defs": {"A": {"type": "string"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
let plain = canonicalize(&json!({"minLength": 4})).expect("canonicalizes");
let merged = if swap {
plain.intersect(&referencing)
} else {
referencing.intersect(&plain)
}
.expect("intersects");
assert!(merged.definition("#/$defs/A").is_some());
}
#[test]
fn intersect_rejects_operands_with_distinct_definition_maps() {
let left = canonicalize(&json!({
"$defs": {"A": {"type": "string"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
let right = canonicalize(&json!({
"$defs": {"B": {"minLength": 4}},
"$ref": "#/$defs/B"
}))
.expect("canonicalizes");
let error = left.intersect(&right).expect_err("the maps differ");
assert!(matches!(
error,
CanonicalizationError::IncompatibleOperands(OperandMismatch::Definitions)
));
assert_eq!(
error.to_string(),
"operands carry different definition maps"
);
}
#[test_case(
&json!({"type": "string", "minLength": 5}),
&json!({"anyOf": [
{"type": ["null", "boolean", "number", "array", "object"]},
{"type": "string", "maxLength": 4}
]});
"string leaf"
)]
#[test_case(
&json!({"type": "number", "minimum": 5}),
&json!({"anyOf": [
{"type": ["null", "boolean", "string", "array", "object"]},
{"type": "number", "exclusiveMaximum": 5}
]});
"number leaf"
)]
#[test_case(
&json!({"type": "object", "required": ["a"]}),
&json!({"anyOf": [
{"type": ["null", "boolean", "number", "string", "array"]},
{"type": "object", "properties": {"a": false}}
]});
"object leaf"
)]
fn negate_spells_the_complement(schema: &Value, expected: &Value) {
let canonical = canonicalize(schema).expect("canonicalizes");
let mut expected = expected.as_object().expect("object").clone();
expected.insert(
"$schema".into(),
json!("https://json-schema.org/draft/2020-12/schema"),
);
assert_eq!(
canonical.negate().expect("negates").to_json_schema(),
Value::Object(expected)
);
}
#[test_case(&json!({"type": "string", "minLength": 5}); "string leaf")]
#[test_case(&json!({"type": "number", "minimum": 5}); "number leaf")]
#[test_case(&json!({"type": "object", "required": ["a"]}); "object leaf")]
#[test_case(&json!({"const": 1.5}); "numeric constant")]
#[test_case(&json!({"type": "array", "items": {"type": "string"}}); "array element schema")]
#[test_case(&json!({"type": "array", "maxItems": 2, "items": {"type": "string"}}); "array element schema beside a size bound")]
#[test_case(&json!({"const": "a"}); "string constant")]
#[test_case(&json!({"enum": ["a", "b"]}); "string value set")]
#[test_case(&json!({"type": "string", "minLength": 2}); "excluded value under a window")]
#[test_case(&json!({"type": "array", "contains": {"type": "string"}}); "array existential demand")]
#[test_case(&json!({"type": "array", "contains": {"const": "a"}}); "array existential demand on a value")]
#[test_case(
&json!({"type": "array", "minItems": 1, "contains": {"type": "string"}});
"array existential demand beside a size bound"
)]
#[test_case(
&json!({"type": "array", "items": {"type": "string"}, "contains": {"const": "a"}});
"array existential demand beside an element schema"
)]
fn negate_admits_exactly_what_the_source_rejects(schema: &Value) {
let complement = canonicalize(schema)
.expect("canonicalizes")
.negate()
.expect("negates")
.to_json_schema();
let source = jsonschema::validator_for(schema).expect("source builds");
let complement = jsonschema::validator_for(&complement).expect("complement builds");
for instance in [
json!(null),
json!(true),
json!(4),
json!(5),
json!(1.5),
json!("abcd"),
json!("abcde"),
json!([]),
json!(["a"]),
json!(["a", "b", "c"]),
json!(["a", 1]),
json!([1]),
json!({}),
json!({"a": 1}),
] {
assert_ne!(
source.is_valid(&instance),
complement.is_valid(&instance),
"{instance} lands on the same side of both"
);
}
}
#[test_case(&json!({"type": "integer"}); "integer leaf")]
#[test_case(&json!({"type": "integer", "minimum": 0}); "bounded integer leaf")]
#[test_case(
&json!({"$schema": "http://json-schema.org/draft-04/schema#", "type": "integer", "enum": [1, 2]});
"typed group"
)]
#[test_case(&json!({"if": {}, "unevaluatedProperties": false}); "raw document")]
#[test_case(
&json!({"$schema": "http://json-schema.org/draft-04/schema#", "type": "array", "items": {"type": "string"}});
"draft 4 array element schema"
)]
#[test_case(
&json!({"type": "array", "contains": {"type": "string"}, "minContains": 2});
"counted array existential demand"
)]
fn negate_declines(schema: &Value) {
assert_eq!(canonicalize(schema).expect("canonicalizes").negate(), None);
}
#[test]
fn negate_keeps_a_reference_symbolic() {
let schema = canonicalize(&json!({
"$defs": {"A": {"type": "string"}},
"$ref": "#/$defs/A"
}))
.expect("canonicalizes");
let complement = schema.negate().expect("negates");
assert_eq!(complement.kind(), CanonicalKind::Not);
assert_eq!(
complement.definition("#/$defs/A"),
schema.definition("#/$defs/A")
);
}
#[test_case(&json!({"const": "a"}); "string constant")]
#[test_case(&json!({"enum": ["a", "b"]}); "string value set")]
#[test_case(&json!({"type": "string", "minLength": 2}); "string window")]
fn negate_is_an_involution(schema: &Value) {
let canonical = canonicalize(schema).expect("canonicalizes");
let doubled = canonical
.negate()
.expect("negates")
.negate()
.expect("negates back");
assert_eq!(doubled, canonical);
}