From bb84f9aa73ba032413d1b65abe10103547a60cf5 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Fri, 12 Sep 2025 01:02:32 -0400 Subject: [PATCH] implemented type match checking for types on schema id instead of type const --- src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++++++++-- src/schemas.rs | 42 ++++++++++++++++++++++++++++++ src/tests.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0a8e7cd..3e2c0b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; use std::collections::hash_map::Entry; use std::{collections::HashMap, sync::RwLock}; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] enum SchemaType { Enum, Type, @@ -20,6 +20,7 @@ enum SchemaType { struct BoonCache { schemas: Schemas, id_to_index: HashMap, + id_to_type: HashMap, } // Structure to hold error information without lifetimes @@ -35,6 +36,7 @@ lazy_static! { static ref SCHEMA_CACHE: RwLock = RwLock::new(BoonCache { schemas: Schemas::new(), id_to_index: HashMap::new(), + id_to_type: HashMap::new(), }); } @@ -49,6 +51,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB { *cache = BoonCache { schemas: Schemas::new(), id_to_index: HashMap::new(), + id_to_type: HashMap::new(), }; // Create the boon compiler and enable format assertions @@ -85,6 +88,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB { })); } else { all_schema_ids.push(schema_id.to_string()); + cache.id_to_type.insert(schema_id.to_string(), SchemaType::Enum); } } } @@ -119,6 +123,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB { })); } else { all_schema_ids.push(schema_id.to_string()); + cache.id_to_type.insert(schema_id.to_string(), SchemaType::Type); } } } @@ -166,6 +171,7 @@ fn cache_json_schemas(enums: JsonB, types: JsonB, puncs: JsonB) -> JsonB { })); } else { all_schema_ids.push(schema_id.to_string()); + cache.id_to_type.insert(schema_id.to_string(), schema_type_for_def); } } } @@ -322,6 +328,47 @@ fn apply_strict_validation_recursive(schema: &mut Value, inside_conditional: boo } } +fn validate_type_against_schema_id(instance: &Value, schema_id: &str) -> JsonB { + let expected_type = schema_id.split('.').next().unwrap_or(schema_id); + + if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) { + if actual_type == expected_type { + return JsonB(json!({ "response": "success" })); + } + } + + // If we reach here, validation failed. Now we build the specific error. + let (message, cause, context) = + if let Some(actual_type) = instance.get("type").and_then(|v| v.as_str()) { + // This handles the case where the type is a string but doesn't match. + ( + format!("Instance type '{}' does not match expected type '{}' derived from schema ID", actual_type, expected_type), + json!({ "expected": expected_type, "actual": actual_type }), + json!(actual_type) + ) + } else { + // This handles the case where 'type' is missing or not a string. + ( + "Instance 'type' property is missing or not a string".to_string(), + json!("The 'type' property must be a string and is required for this validation."), + instance.get("type").unwrap_or(&Value::Null).clone() + ) + }; + + JsonB(json!({ + "errors": [{ + "code": "TYPE_MISMATCH", + "message": message, + "details": { + "path": "/type", + "context": context, + "cause": cause, + "schema": schema_id + } + }] + })) +} + #[pg_extern(strict, parallel_safe)] fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB { let cache = SCHEMA_CACHE.read().unwrap(); @@ -340,7 +387,16 @@ fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB { Some(sch_index) => { let instance_value: Value = instance.0; match cache.schemas.validate(&instance_value, *sch_index) { - Ok(_) => JsonB(json!({ "response": "success" })), + Ok(_) => { + // After standard validation, perform custom type check if it's a Type schema + if let Some(&schema_type) = cache.id_to_type.get(schema_id) { + if schema_type == SchemaType::Type { + return validate_type_against_schema_id(&instance_value, schema_id); + } + } + // For non-Type schemas, or if type not found (shouldn't happen), success. + JsonB(json!({ "response": "success" })) + } Err(validation_error) => { let mut error_list = Vec::new(); collect_errors(&validation_error, &mut error_list); @@ -961,6 +1017,7 @@ fn clear_json_schemas() -> JsonB { *cache = BoonCache { schemas: Schemas::new(), id_to_index: HashMap::new(), + id_to_type: HashMap::new(), }; JsonB(json!({ "response": "success" })) } diff --git a/src/schemas.rs b/src/schemas.rs index 3bbb30a..1d2ab8c 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -761,3 +761,45 @@ pub fn title_override_schemas() -> JsonB { cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) } + +pub fn type_matching_schemas() -> JsonB { + let enums = json!([]); + let types = json!([ + { + "name": "entity", + "schemas": [{ + "$id": "entity", + "type": "object", + "properties": { "type": { "type": "string" }, "name": { "type": "string" } }, + "required": ["type", "name"] + }] + }, + { + "name": "job", + "schemas": [{ + "$id": "job", + "$ref": "entity", + "properties": { "job_id": { "type": "string" } }, + "required": ["job_id"] + }] + }, + { + "name": "super_job", + "schemas": [ + { + "$id": "super_job", + "$ref": "job", + "properties": { "manager_id": { "type": "string" } }, + "required": ["manager_id"] + }, + { + "$id": "super_job.short", + "$ref": "super_job", + "properties": { "name": { "maxLength": 10 } } + } + ] + } + ]); + let puncs = json!([]); + cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) +} \ No newline at end of file diff --git a/src/tests.rs b/src/tests.rs index 1919f38..8cb4ac4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -506,6 +506,7 @@ fn test_validate_property_merging() { // entity (id, name) + user (password) + person (first_name, last_name) let valid_person_with_all_properties = json!({ + "type": "person", // Added to satisfy new type check // From entity "id": "550e8400-e29b-41d4-a716-446655440000", "name": "John Doe", @@ -523,6 +524,7 @@ fn test_validate_property_merging() { // Test that properties validate according to their schema definitions across the chain let invalid_mixed_properties = json!({ + "type": "person", // Added to satisfy new type check "id": "550e8400-e29b-41d4-a716-446655440000", "name": "John Doe", "password": "short", // Too short from user schema @@ -546,15 +548,13 @@ fn test_validate_required_merging() { // user: ["password"] (conditional when type=user) // person: ["first_name", "last_name"] (conditional when type=person) - let missing_all_required = json!({}); + let missing_all_required = json!({ "type": "person" }); // Add type to pass initial check let result = validate_json_schema("person", jsonb(missing_all_required)); - // Should fail for all required fields across inheritance chain - assert_error_count(&result, 6); // id, type, created_by, password, first_name, last_name + // Should fail for all required fields across inheritance chain, except for the conditional 'password' + assert_error_count(&result, 4); // id, created_by, first_name, last_name assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/id"); - assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/type"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/created_by"); - assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/password"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/first_name"); assert_has_error(&result, "REQUIRED_FIELD_MISSING", "/last_name"); @@ -777,14 +777,69 @@ fn test_validate_title_override() { // Test that a schema with an overridden title still inherits validation keywords correctly. // This instance is valid because it provides the 'name' required by the base schema. - let valid_instance = json!({ "name": "Test Name" }); + let valid_instance = json!({ "type": "override_with_title", "name": "Test Name" }); let result_valid = validate_json_schema("override_with_title", jsonb(valid_instance)); assert_success(&result_valid); // This instance is invalid because it's missing the 'name' required by the base schema. // This proves that validation keywords are inherited even when metadata keywords are overridden. - let invalid_instance = json!({}); + let invalid_instance = json!({ "type": "override_with_title" }); let result_invalid = validate_json_schema("override_with_title", jsonb(invalid_instance)); assert_error_count(&result_invalid, 1); assert_has_error(&result_invalid, "REQUIRED_FIELD_MISSING", "/name"); +} + +#[pg_test] +fn test_validate_type_matching() { + let cache_result = type_matching_schemas(); + assert_success(&cache_result); + + // 1. Test 'job' which extends 'entity' + let valid_job = json!({ + "type": "job", + "name": "my job", + "job_id": "job123" + }); + let result_valid_job = validate_json_schema("job", jsonb(valid_job)); + assert_success(&result_valid_job); + + let invalid_job = json!({ + "type": "not_job", + "name": "my job", + "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"); + + // 2. Test 'super_job' which extends 'job' + let valid_super_job = json!({ + "type": "super_job", + "name": "my super job", + "job_id": "job123", + "manager_id": "mgr1" + }); + let result_valid_super_job = validate_json_schema("super_job", jsonb(valid_super_job)); + assert_success(&result_valid_super_job); + + // 3. Test 'super_job.short' which should still expect type 'super_job' + let valid_short_super_job = json!({ + "type": "super_job", + "name": "short", // maxLength: 10 + "job_id": "job123", + "manager_id": "mgr1" + }); + let result_valid_short = validate_json_schema("super_job.short", jsonb(valid_short_super_job)); + assert_success(&result_valid_short); + + let invalid_short_super_job = json!({ + "type": "job", // Should be 'super_job' + "name": "short", + "job_id": "job123", + "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'"); } \ No newline at end of file