diff --git a/src/lib.rs b/src/lib.rs index 5941618..e66a276 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,11 +20,16 @@ lazy_static! { } #[pg_extern(strict)] -fn cache_json_schema(schema_id: &str, schema: JsonB) -> JsonB { +fn cache_json_schema(schema_id: &str, schema: JsonB, strict: bool) -> JsonB { let mut cache = SCHEMA_CACHE.write().unwrap(); - let schema_value: Value = schema.0; + let mut schema_value: Value = schema.0; let schema_path = format!("urn:{}", schema_id); + // Apply strict validation to all objects in the schema if requested + if strict { + apply_strict_validation(&mut schema_value); + } + let mut compiler = Compiler::new(); compiler.enable_format_assertions(); @@ -75,6 +80,32 @@ fn cache_json_schema(schema_id: &str, schema: JsonB) -> JsonB { } } +// Helper function to recursively apply strict validation to all objects in a schema +fn apply_strict_validation(schema: &mut Value) { + match schema { + Value::Object(map) => { + // If this is an object type schema, add additionalProperties: false + if let Some(Value::String(t)) = map.get("type") { + if t == "object" && !map.contains_key("additionalProperties") { + map.insert("additionalProperties".to_string(), Value::Bool(false)); + } + } + + // Recurse into all properties + for (_, value) in map.iter_mut() { + apply_strict_validation(value); + } + } + Value::Array(arr) => { + // Recurse into array items + for item in arr.iter_mut() { + apply_strict_validation(item); + } + } + _ => {} + } +} + #[pg_extern(strict, parallel_safe)] fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB { let cache = SCHEMA_CACHE.read().unwrap(); diff --git a/src/tests.rs b/src/tests.rs index 706caa8..b259c08 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -143,7 +143,7 @@ fn test_cache_and_validate_json_schema() { let invalid_instance_type = json!({ "name": "Bob", "age": -5 }); let invalid_instance_missing = json!({ "name": "Charlie" }); - let cache_result = cache_json_schema(schema_id, jsonb(schema.clone())); + let cache_result = cache_json_schema(schema_id, jsonb(schema.clone()), false); assert_success_with_json!(cache_result, "Cache operation should succeed."); let valid_result = validate_json_schema(schema_id, jsonb(valid_instance)); @@ -190,7 +190,7 @@ fn test_cache_invalid_json_schema() { "type": ["invalid_type_value"] }); - let cache_result = cache_json_schema(schema_id, jsonb(invalid_schema)); + let cache_result = cache_json_schema(schema_id, jsonb(invalid_schema), false); // Expect 2 leaf errors because the meta-schema validation fails at the type value // and within the type array itself. @@ -233,7 +233,7 @@ fn test_validate_json_schema_detailed_validation_errors() { }, "required": ["address"] }); - let _ = cache_json_schema(schema_id, jsonb(schema)); + let _ = cache_json_schema(schema_id, jsonb(schema), false); let invalid_instance = json!({ "address": { @@ -272,7 +272,7 @@ fn test_validate_json_schema_oneof_validation_errors() { ] }); - let _ = cache_json_schema(schema_id, jsonb(schema)); + let _ = cache_json_schema(schema_id, jsonb(schema), false); // --- Test case 1: Fails string maxLength (in branch 0) AND missing number_prop (in branch 1) --- let invalid_string_instance = json!({ "string_prop": "toolongstring" }); @@ -340,7 +340,7 @@ fn test_clear_json_schemas() { let schema_id = "schema_to_clear"; let schema = json!({ "type": "string" }); - let cache_result = cache_json_schema(schema_id, jsonb(schema.clone())); + let cache_result = cache_json_schema(schema_id, jsonb(schema.clone()), false); assert_success_with_json!(cache_result); let show_result1 = show_json_schemas(); @@ -366,8 +366,8 @@ fn test_show_json_schemas() { let schema_id2 = "schema2"; let schema = json!({ "type": "boolean" }); - let _ = cache_json_schema(schema_id1, jsonb(schema.clone())); - let _ = cache_json_schema(schema_id2, jsonb(schema.clone())); + let _ = cache_json_schema(schema_id1, jsonb(schema.clone()), false); + let _ = cache_json_schema(schema_id2, jsonb(schema.clone()), false); let result = show_json_schemas(); let schemas = result.0["response"].as_array().unwrap(); @@ -375,3 +375,167 @@ fn test_show_json_schemas() { assert!(schemas.contains(&json!(schema_id1))); assert!(schemas.contains(&json!(schema_id2))); } + +#[pg_test] +fn test_auto_strict_validation() { + clear_json_schemas(); + let schema_id = "strict_test"; + let schema_id_non_strict = "non_strict_test"; + + // Schema without explicit additionalProperties: false + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "profile": { + "type": "object", + "properties": { + "age": { "type": "number" }, + "preferences": { + "type": "object", + "properties": { + "theme": { "type": "string" } + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "value": { "type": "string" } + } + } + } + } + }); + + // Cache the same schema twice - once with strict=true, once with strict=false + let cache_result_strict = cache_json_schema(schema_id, jsonb(schema.clone()), true); + assert_success_with_json!(cache_result_strict, "Schema caching with strict=true should succeed"); + + let cache_result_non_strict = cache_json_schema(schema_id_non_strict, jsonb(schema.clone()), false); + assert_success_with_json!(cache_result_non_strict, "Schema caching with strict=false should succeed"); + + // Test 1: Valid instance with no extra properties (should pass for both) + let valid_instance = json!({ + "name": "John", + "profile": { + "age": 30, + "preferences": { + "theme": "dark" + } + }, + "tags": [ + {"id": "1", "value": "rust"}, + {"id": "2", "value": "postgres"} + ] + }); + + let valid_result_strict = validate_json_schema(schema_id, jsonb(valid_instance.clone())); + assert_success_with_json!(valid_result_strict, "Valid instance should pass with strict schema"); + + let valid_result_non_strict = validate_json_schema(schema_id_non_strict, jsonb(valid_instance)); + assert_success_with_json!(valid_result_non_strict, "Valid instance should pass with non-strict schema"); + + // Test 2: Root level extra property + let invalid_root_extra = json!({ + "name": "John", + "extraField": "should fail" // Extra property at root + }); + + // Should fail with strict schema + let result_root_strict = validate_json_schema(schema_id, jsonb(invalid_root_extra.clone())); + assert_failure_with_json!(result_root_strict, 1, "Object contains properties that are not allowed"); + let errors_root = result_root_strict.0["errors"].as_array().unwrap(); + assert_eq!(errors_root[0]["code"], "ADDITIONAL_PROPERTIES_NOT_ALLOWED"); + assert_eq!(errors_root[0]["details"]["path"], ""); + + // Should pass with non-strict schema + let result_root_non_strict = validate_json_schema(schema_id_non_strict, jsonb(invalid_root_extra)); + assert_success_with_json!(result_root_non_strict, "Extra property should be allowed with non-strict schema"); + + // Test 3: Nested object extra property + let invalid_nested_extra = json!({ + "name": "John", + "profile": { + "age": 30, + "extraNested": "should fail" // Extra property in nested object + } + }); + + // Should fail with strict schema + let result_nested_strict = validate_json_schema(schema_id, jsonb(invalid_nested_extra.clone())); + assert_failure_with_json!(result_nested_strict, 1, "Object contains properties that are not allowed"); + let errors_nested = result_nested_strict.0["errors"].as_array().unwrap(); + assert_eq!(errors_nested[0]["code"], "ADDITIONAL_PROPERTIES_NOT_ALLOWED"); + assert_eq!(errors_nested[0]["details"]["path"], "/profile"); + + // Should pass with non-strict schema + let result_nested_non_strict = validate_json_schema(schema_id_non_strict, jsonb(invalid_nested_extra)); + assert_success_with_json!(result_nested_non_strict, "Extra nested property should be allowed with non-strict schema"); + + // Test 4: Deeply nested object extra property + let invalid_deep_extra = json!({ + "name": "John", + "profile": { + "age": 30, + "preferences": { + "theme": "dark", + "extraDeep": "should fail" // Extra property in deeply nested object + } + } + }); + + // Should fail with strict schema + let result_deep_strict = validate_json_schema(schema_id, jsonb(invalid_deep_extra.clone())); + assert_failure_with_json!(result_deep_strict, 1, "Object contains properties that are not allowed"); + let errors_deep = result_deep_strict.0["errors"].as_array().unwrap(); + assert_eq!(errors_deep[0]["code"], "ADDITIONAL_PROPERTIES_NOT_ALLOWED"); + assert_eq!(errors_deep[0]["details"]["path"], "/profile/preferences"); + + // Should pass with non-strict schema + let result_deep_non_strict = validate_json_schema(schema_id_non_strict, jsonb(invalid_deep_extra)); + assert_success_with_json!(result_deep_non_strict, "Extra deep property should be allowed with non-strict schema"); + + // Test 5: Array item extra property + let invalid_array_item_extra = json!({ + "name": "John", + "tags": [ + {"id": "1", "value": "rust", "extraInArray": "should fail"} // Extra property in array item + ] + }); + + // Should fail with strict schema + let result_array_strict = validate_json_schema(schema_id, jsonb(invalid_array_item_extra.clone())); + assert_failure_with_json!(result_array_strict, 1, "Object contains properties that are not allowed"); + let errors_array = result_array_strict.0["errors"].as_array().unwrap(); + assert_eq!(errors_array[0]["code"], "ADDITIONAL_PROPERTIES_NOT_ALLOWED"); + assert_eq!(errors_array[0]["details"]["path"], "/tags/0"); + + // Should pass with non-strict schema + let result_array_non_strict = validate_json_schema(schema_id_non_strict, jsonb(invalid_array_item_extra)); + assert_success_with_json!(result_array_non_strict, "Extra array item property should be allowed with non-strict schema"); + + // Test 6: Schema with explicit additionalProperties: true should allow extras even with strict=true + let schema_id_permissive = "permissive_test"; + let permissive_schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "additionalProperties": true // Explicitly allow additional properties + }); + + let _ = cache_json_schema(schema_id_permissive, jsonb(permissive_schema), true); // Note: strict=true + + let instance_with_extra = json!({ + "name": "John", + "extraAllowed": "should pass" + }); + + let result_permissive = validate_json_schema(schema_id_permissive, jsonb(instance_with_extra)); + assert_success_with_json!(result_permissive, "Instance with extra property should pass when additionalProperties is explicitly true, even with strict=true"); +}