bringing back type constants for validation via new overrides vocabulary

This commit is contained in:
2025-10-08 12:38:26 -04:00
parent 44cde90c3d
commit f3d157ebcb
8 changed files with 202 additions and 228 deletions

View File

@ -189,147 +189,6 @@ fn compile_all_schemas(
}
}
fn walk_and_validate_refs(
instance: &Value,
schema: &Value,
cache: &std::sync::RwLockReadGuard<Cache>,
path_parts: &mut Vec<String>,
type_validated: bool,
top_level_id: Option<&str>,
errors: &mut Vec<Value>,
) {
if let Some(ref_url) = schema.get("$ref").and_then(|v| v.as_str()) {
if let Some(s) = cache.map.get(ref_url) {
let mut new_type_validated = type_validated;
if !type_validated && s.t == SchemaType::Type {
let id_to_use = top_level_id.unwrap_or(ref_url);
let expected_type = id_to_use.split('.').next().unwrap_or(id_to_use);
if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) {
if actual_type == expected_type {
new_type_validated = true;
} else {
path_parts.push("type".to_string());
let path = format!("/{}", path_parts.join("/"));
path_parts.pop();
errors.push(json!({
"code": "TYPE_MISMATCH",
"message": format!("Instance type '{}' does not match expected type '{}' derived from schema $ref", actual_type, expected_type),
"details": { "path": path, "context": instance, "cause": { "expected": expected_type, "actual": actual_type }, "schema": ref_url }
}));
}
} else {
if top_level_id.is_some() {
let path = if path_parts.is_empty() { "".to_string() } else { format!("/{}", path_parts.join("/")) };
errors.push(json!({
"code": "TYPE_MISMATCH",
"message": "Instance is missing 'type' property required for schema validation",
"details": { "path": path, "context": instance, "cause": { "expected": expected_type }, "schema": ref_url }
}));
}
}
}
walk_and_validate_refs(instance, &s.value, cache, path_parts, new_type_validated, None, errors);
}
}
if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
for (prop_name, prop_schema) in properties {
if let Some(prop_value) = instance.get(prop_name) {
path_parts.push(prop_name.clone());
walk_and_validate_refs(prop_value, prop_schema, cache, path_parts, type_validated, None, errors);
path_parts.pop();
}
}
}
if let Some(items_schema) = schema.get("items") {
if let Some(instance_array) = instance.as_array() {
for (i, item) in instance_array.iter().enumerate() {
path_parts.push(i.to_string());
walk_and_validate_refs(item, items_schema, cache, path_parts, false, None, errors);
path_parts.pop();
}
}
}
if let Some(all_of_array) = schema.get("allOf").and_then(|v| v.as_array()) {
for sub_schema in all_of_array {
walk_and_validate_refs(instance, sub_schema, cache, path_parts, type_validated, None, errors);
}
}
if let Some(any_of_array) = schema.get("anyOf").and_then(|v| v.as_array()) {
for sub_schema in any_of_array {
walk_and_validate_refs(instance, sub_schema, cache, path_parts, type_validated, None, errors);
}
}
if let Some(one_of_array) = schema.get("oneOf").and_then(|v| v.as_array()) {
let is_clean_ref_union = one_of_array.iter().all(|s| s.get("$ref").is_some());
if is_clean_ref_union {
if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) {
let mut match_found = false;
for sub_schema in one_of_array {
if let Some(ref_url) = sub_schema.get("$ref").and_then(|v| v.as_str()) {
if ref_url == actual_type {
walk_and_validate_refs(instance, sub_schema, cache, path_parts, type_validated, None, errors);
match_found = true;
break;
}
}
}
if !match_found {
let path = format!("/{}", path_parts.join("/"));
errors.push(json!({
"code": "TYPE_MISMATCH_IN_UNION",
"message": format!("Instance type '{}' does not match any of the allowed types in the union", actual_type),
"details": {
"path": path,
"context": instance,
"cause": {
"actual": actual_type,
"expected": one_of_array.iter()
.filter_map(|s| s.get("$ref").and_then(|r| r.as_str()))
.collect::<Vec<_>>()
},
"schema": top_level_id.unwrap_or("")
}
}));
}
} else {
let path = format!("/{}", path_parts.join("/"));
errors.push(json!({
"code": "TYPE_REQUIRED_FOR_UNION",
"message": "Instance is missing 'type' property required for union (oneOf) validation",
"details": { "path": path, "context": instance, "schema": top_level_id.unwrap_or("") }
}));
}
return;
} else {
for sub_schema in one_of_array {
walk_and_validate_refs(instance, sub_schema, cache, path_parts, type_validated, None, errors);
}
}
}
if let Some(if_schema) = schema.get("if") {
walk_and_validate_refs(instance, if_schema, cache, path_parts, type_validated, None, errors);
}
if let Some(then_schema) = schema.get("then") {
walk_and_validate_refs(instance, then_schema, cache, path_parts, type_validated, None, errors);
}
if let Some(else_schema) = schema.get("else") {
walk_and_validate_refs(instance, else_schema, cache, path_parts, type_validated, None, errors);
}
if let Some(not_schema) = schema.get("not") {
walk_and_validate_refs(instance, not_schema, cache, path_parts, type_validated, None, errors);
}
}
#[pg_extern(strict, parallel_safe)]
fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB {
let cache = SCHEMA_CACHE.read().unwrap();
@ -353,18 +212,7 @@ fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB {
match cache.schemas.validate(&instance_value, schema.index, options) {
Ok(_) => {
let mut custom_errors = Vec::new();
if schema.t == SchemaType::Type || schema.t == SchemaType::PublicPunc || schema.t == SchemaType::PrivatePunc {
let mut path_parts = vec![];
let top_level_id = if schema.t == SchemaType::Type { Some(schema_id) } else { None };
walk_and_validate_refs(&instance_value, &schema.value, &cache, &mut path_parts, false, top_level_id, &mut custom_errors);
}
if custom_errors.is_empty() {
JsonB(json!({ "response": "success" }))
} else {
JsonB(json!({ "errors": custom_errors }))
}
}
Err(validation_error) => {
let mut error_list = Vec::new();

View File

@ -465,7 +465,7 @@ pub fn property_merging_schemas() -> JsonB {
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" }
"type": { "type": "string", "const": "entity" }
},
"required": ["id"]
}]
@ -476,6 +476,7 @@ pub fn property_merging_schemas() -> JsonB {
"$id": "user",
"$ref": "entity",
"properties": {
"type": { "type": "string", "const": "user", "override": true },
"password": { "type": "string", "minLength": 8 }
},
"required": ["password"]
@ -487,6 +488,7 @@ pub fn property_merging_schemas() -> JsonB {
"$id": "person",
"$ref": "user",
"properties": {
"type": { "type": "string", "const": "person", "override": true },
"first_name": { "type": "string", "minLength": 1 },
"last_name": { "type": "string", "minLength": 1 }
},
@ -852,7 +854,10 @@ pub fn type_matching_schemas() -> JsonB {
"schemas": [{
"$id": "entity",
"type": "object",
"properties": { "type": { "type": "string" }, "name": { "type": "string" } },
"properties": {
"type": { "type": "string", "const": "entity" },
"name": { "type": "string" }
},
"required": ["type", "name"]
}]
},
@ -861,7 +866,10 @@ pub fn type_matching_schemas() -> JsonB {
"schemas": [{
"$id": "job",
"$ref": "entity",
"properties": { "job_id": { "type": "string" } },
"properties": {
"type": { "type": "string", "const": "job", "override": true },
"job_id": { "type": "string" }
},
"required": ["job_id"]
}]
},
@ -871,7 +879,10 @@ pub fn type_matching_schemas() -> JsonB {
{
"$id": "super_job",
"$ref": "job",
"properties": { "manager_id": { "type": "string" } },
"properties": {
"type": { "type": "string", "const": "super_job", "override": true },
"manager_id": { "type": "string" }
},
"required": ["manager_id"]
},
{
@ -912,47 +923,59 @@ pub fn type_matching_schemas() -> JsonB {
pub fn union_schemas() -> JsonB {
let enums = json!([]);
let types = json!([
{
"name": "union_base",
"schemas": [{
"$id": "union_base",
"type": "object",
"properties": {
"type": { "type": "string", "const": "union_base" },
"id": { "type": "string" }
},
"required": ["type", "id"]
}]
},
{
"name": "union_a",
"schemas": [{
"$id": "union_a",
"type": "object",
"$ref": "union_base",
"properties": {
"type": { "const": "union_a" },
"type": { "type": "string", "const": "union_a", "override": true },
"prop_a": { "type": "string" }
},
"required": ["type", "prop_a"]
"required": ["prop_a"]
}]
},
{
"name": "union_b",
"schemas": [{
"$id": "union_b",
"type": "object",
"$ref": "union_base",
"properties": {
"type": { "const": "union_b" },
"type": { "type": "string", "const": "union_b", "override": true },
"prop_b": { "type": "number" }
},
"required": ["type", "prop_b"]
"required": ["prop_b"]
}]
},
{
"name": "union_c",
"schemas": [{
"$id": "union_c",
"type": "object",
"$ref": "union_base",
"properties": {
"type": { "const": "union_c" },
"type": { "type": "string", "const": "union_c", "override": true },
"prop_c": { "type": "boolean" }
},
"required": ["type", "prop_c"]
"required": ["prop_c"]
}]
}
]);
let puncs = json!([{
"name": "union_test",
"public": false,
"public": true,
"schemas": [{
"$id": "union_test.request",
"type": "object",
@ -975,23 +998,47 @@ pub fn union_schemas() -> JsonB {
pub fn nullable_union_schemas() -> JsonB {
let enums = json!([]);
let types = json!([
{
"name": "thing_base",
"schemas": [{
"$id": "thing_base",
"type": "object",
"properties": {
"type": { "type": "string", "const": "thing_base" },
"id": { "type": "string" }
},
"required": ["type", "id"]
}]
},
{
"name": "thing_a",
"schemas": [{
"$id": "thing_a",
"type": "object",
"$ref": "thing_base",
"properties": {
"type": { "const": "thing_a" },
"type": { "type": "string", "const": "thing_a", "override": true },
"prop_a": { "type": "string" }
},
"required": ["type", "prop_a"]
"required": ["prop_a"]
}]
},
{
"name": "thing_b",
"schemas": [{
"$id": "thing_b",
"$ref": "thing_base",
"properties": {
"type": { "type": "string", "const": "thing_b", "override": true },
"prop_b": { "type": "string" }
},
"required": ["prop_b"]
}]
}
]);
let puncs = json!([{
"name": "nullable_union_test",
"public": false,
"public": true,
"schemas": [{
"$id": "nullable_union_test.request",
"type": "object",
@ -999,6 +1046,7 @@ pub fn nullable_union_schemas() -> JsonB {
"nullable_prop": {
"oneOf": [
{ "$ref": "thing_a" },
{ "$ref": "thing_b" },
{ "type": "null" }
]
}

View File

@ -824,8 +824,8 @@ fn test_validate_type_matching() {
"job_id": "job123"
});
let result_invalid_job = validate_json_schema("job", jsonb(invalid_job));
assert_error_count(&result_invalid_job, 1);
assert_has_error(&result_invalid_job, "TYPE_MISMATCH", "/type");
assert_failure(&result_invalid_job);
assert_has_error(&result_invalid_job, "CONST_VIOLATED", "/type");
// 2. Test 'super_job' which extends 'job'
let valid_super_job = json!({
@ -854,9 +854,8 @@ fn test_validate_type_matching() {
"manager_id": "mgr1"
});
let result_invalid_short = validate_json_schema("super_job.short", jsonb(invalid_short_super_job));
assert_error_count(&result_invalid_short, 1);
let error = find_error_with_code_and_path(&result_invalid_short, "TYPE_MISMATCH", "/type");
assert_error_message_contains(error, "Instance type 'job' does not match expected type 'super_job'");
assert_failure(&result_invalid_short);
assert_has_error(&result_invalid_short, "CONST_VIOLATED", "/type");
// 4. Test punc with root, nested, and oneOf type refs
let valid_punc_instance = json!({
@ -890,8 +889,8 @@ fn test_validate_type_matching() {
}
});
let result_invalid_punc_root = validate_json_schema("type_test_punc.request", jsonb(invalid_punc_root));
assert_error_count(&result_invalid_punc_root, 1);
assert_has_error(&result_invalid_punc_root, "TYPE_MISMATCH", "/root_job/type");
assert_failure(&result_invalid_punc_root);
assert_has_error(&result_invalid_punc_root, "CONST_VIOLATED", "/root_job/type");
// 6. Test invalid type at punc nested ref
let invalid_punc_nested = json!({
@ -909,8 +908,8 @@ fn test_validate_type_matching() {
}
});
let result_invalid_punc_nested = validate_json_schema("type_test_punc.request", jsonb(invalid_punc_nested));
assert_error_count(&result_invalid_punc_nested, 1);
assert_has_error(&result_invalid_punc_nested, "TYPE_MISMATCH", "/nested_or_super_job/my_job/type");
assert_failure(&result_invalid_punc_nested);
assert_has_error(&result_invalid_punc_nested, "CONST_VIOLATED", "/nested_or_super_job/my_job/type");
// 7. Test invalid type at punc oneOf ref
let invalid_punc_oneof = json!({
@ -927,8 +926,8 @@ fn test_validate_type_matching() {
}
});
let result_invalid_punc_oneof = validate_json_schema("type_test_punc.request", jsonb(invalid_punc_oneof));
// This will have multiple errors because the invalid oneOf branch will also fail the other branch's validation
assert_has_error(&result_invalid_punc_oneof, "TYPE_MISMATCH", "/nested_or_super_job/type");
assert_failure(&result_invalid_punc_oneof);
assert_has_error(&result_invalid_punc_oneof, "CONST_VIOLATED", "/nested_or_super_job/type");
}
#[pg_test]
@ -939,6 +938,7 @@ fn test_validate_union_type_matching() {
// 1. Test valid instance with type 'union_a'
let valid_instance_a = json!({
"union_prop": {
"id": "123",
"type": "union_a",
"prop_a": "hello"
}
@ -949,6 +949,7 @@ fn test_validate_union_type_matching() {
// 2. Test valid instance with type 'union_b'
let valid_instance_b = json!({
"union_prop": {
"id": "456",
"type": "union_b",
"prop_b": 123
}
@ -956,50 +957,30 @@ fn test_validate_union_type_matching() {
let result_b = validate_json_schema("union_test.request", jsonb(valid_instance_b));
assert_success(&result_b);
// 3. Test invalid instance - correct type, but fails sub-schema validation
// 3. Test invalid instance - wrong type const in a valid oneOf branch
let invalid_sub_schema = json!({
"union_prop": {
"type": "union_a",
"prop_a": 123 // prop_a should be a string
"id": "789",
"type": "union_b", // Should be union_a
"prop_a": "hello"
}
});
let result_invalid_sub = validate_json_schema("union_test.request", jsonb(invalid_sub_schema));
// Expect 4 errors because the instance fails validation against all 3 sub-schemas for different reasons,
// and the error collector flattens all unique-path errors.
assert_error_count(&result_invalid_sub, 4);
// The "correct" error from the matched branch 'union_a'
assert_has_error(&result_invalid_sub, "TYPE_MISMATCH", "/union_prop/prop_a");
// Noise from failing the 'union_b' schema
assert_failure(&result_invalid_sub);
// This should fail because the `type` override in `union_a` is `const: "union_a"`
assert_has_error(&result_invalid_sub, "CONST_VIOLATED", "/union_prop/type");
assert_has_error(&result_invalid_sub, "REQUIRED_FIELD_MISSING", "/union_prop/prop_b");
// Noise from failing the 'union_c' schema
assert_has_error(&result_invalid_sub, "REQUIRED_FIELD_MISSING", "/union_prop/prop_c");
// 4. Test invalid instance - type does not match any union member
let invalid_type = json!({
// 4. Test invalid instance - base type, should fail due to override
let invalid_base_type = json!({
"union_prop": {
"type": "union_d", // not a valid type in the oneOf
"prop_d": "whatever"
"id": "101",
"type": "union_base", // This is the base type, but the override should be enforced
"prop_a": "world"
}
});
let result_invalid_type = validate_json_schema("union_test.request", jsonb(invalid_type));
assert_error_count(&result_invalid_type, 4);
assert_has_error(&result_invalid_type, "CONST_VIOLATED", "/union_prop/type");
assert_has_error(&result_invalid_type, "REQUIRED_FIELD_MISSING", "/union_prop/prop_a");
assert_has_error(&result_invalid_type, "REQUIRED_FIELD_MISSING", "/union_prop/prop_b");
assert_has_error(&result_invalid_type, "REQUIRED_FIELD_MISSING", "/union_prop/prop_c");
// 5. Test invalid instance - missing 'type' property for union
let missing_type = json!({
"union_prop": {
"prop_a": "hello" // no 'type' field
}
});
let result_missing_type = validate_json_schema("union_test.request", jsonb(missing_type));
assert_error_count(&result_missing_type, 3);
assert_has_error(&result_missing_type, "REQUIRED_FIELD_MISSING", "/union_prop/type");
assert_has_error(&result_missing_type, "REQUIRED_FIELD_MISSING", "/union_prop/prop_b");
assert_has_error(&result_missing_type, "REQUIRED_FIELD_MISSING", "/union_prop/prop_c");
let result_invalid_base = validate_json_schema("union_test.request", jsonb(invalid_base_type));
assert_failure(&result_invalid_base);
assert_has_error(&result_invalid_base, "CONST_VIOLATED", "/union_prop/type");
}
#[pg_test]
@ -1007,30 +988,52 @@ fn test_validate_nullable_union() {
let cache_result = nullable_union_schemas();
assert_success(&cache_result);
// 1. Test valid instance with the object type
let valid_object = json!({
// 1. Test valid instance with object type 'thing_a'
let valid_object_a = json!({
"nullable_prop": {
"id": "123",
"type": "thing_a",
"prop_a": "hello"
}
});
let result_obj = validate_json_schema("nullable_union_test.request", jsonb(valid_object));
assert_success(&result_obj);
let result_obj_a = validate_json_schema("nullable_union_test.request", jsonb(valid_object_a));
assert_success(&result_obj_a);
// 2. Test valid instance with null
// 2. Test valid instance with object type 'thing_b'
let valid_object_b = json!({
"nullable_prop": {
"id": "456",
"type": "thing_b",
"prop_b": "goodbye"
}
});
let result_obj_b = validate_json_schema("nullable_union_test.request", jsonb(valid_object_b));
assert_success(&result_obj_b);
// 3. Test valid instance with null
let valid_null = json!({
"nullable_prop": null
});
let result_null = validate_json_schema("nullable_union_test.request", jsonb(valid_null));
assert_success(&result_null);
// 3. Test invalid instance (e.g., a string)
// 4. Test invalid instance - base type, should fail due to override
let invalid_base_type = json!({
"nullable_prop": {
"id": "789",
"type": "thing_base",
"prop_a": "should fail"
}
});
let result_invalid_base = validate_json_schema("nullable_union_test.request", jsonb(invalid_base_type));
assert_failure(&result_invalid_base);
assert_has_error(&result_invalid_base, "CONST_VIOLATED", "/nullable_prop/type");
// 5. Test invalid instance (e.g., a string)
let invalid_string = json!({
"nullable_prop": "not_an_object_or_null"
});
let result_invalid = validate_json_schema("nullable_union_test.request", jsonb(invalid_string));
assert_failure(&result_invalid);
// The boon validator will report that the string doesn't match either schema in the oneOf.
// We expect at least one TYPE_MISMATCH error at the path of the property.
assert_has_error(&result_invalid, "TYPE_MISMATCH", "/nullable_prop");
}