From 44cde90c3dc45c27e793dca08daaba8ee8a4a504 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Tue, 7 Oct 2025 20:43:23 -0400 Subject: [PATCH] jspg union fixes --- src/lib.rs | 47 +++++++++++++++++++++- src/schemas.rs | 101 +++++++++++++++++++++++++++++++++++++++++++++++ src/tests.rs | 104 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e0f27ec..334b3cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -265,8 +265,51 @@ fn walk_and_validate_refs( } if let Some(one_of_array) = schema.get("oneOf").and_then(|v| v.as_array()) { - for sub_schema in one_of_array { - walk_and_validate_refs(instance, sub_schema, cache, path_parts, type_validated, None, errors); + 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::>() + }, + "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); + } } } diff --git a/src/schemas.rs b/src/schemas.rs index 54e2a59..75efee4 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -906,5 +906,106 @@ pub fn type_matching_schemas() -> JsonB { "required": ["root_job", "nested_or_super_job"] }] }]); + cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) +} + +pub fn union_schemas() -> JsonB { + let enums = json!([]); + let types = json!([ + { + "name": "union_a", + "schemas": [{ + "$id": "union_a", + "type": "object", + "properties": { + "type": { "const": "union_a" }, + "prop_a": { "type": "string" } + }, + "required": ["type", "prop_a"] + }] + }, + { + "name": "union_b", + "schemas": [{ + "$id": "union_b", + "type": "object", + "properties": { + "type": { "const": "union_b" }, + "prop_b": { "type": "number" } + }, + "required": ["type", "prop_b"] + }] + }, + { + "name": "union_c", + "schemas": [{ + "$id": "union_c", + "type": "object", + "properties": { + "type": { "const": "union_c" }, + "prop_c": { "type": "boolean" } + }, + "required": ["type", "prop_c"] + }] + } + ]); + + let puncs = json!([{ + "name": "union_test", + "public": false, + "schemas": [{ + "$id": "union_test.request", + "type": "object", + "properties": { + "union_prop": { + "oneOf": [ + { "$ref": "union_a" }, + { "$ref": "union_b" }, + { "$ref": "union_c" } + ] + } + }, + "required": ["union_prop"] + }] + }]); + + cache_json_schemas(jsonb(enums), jsonb(types), jsonb(puncs)) +} + +pub fn nullable_union_schemas() -> JsonB { + let enums = json!([]); + let types = json!([ + { + "name": "thing_a", + "schemas": [{ + "$id": "thing_a", + "type": "object", + "properties": { + "type": { "const": "thing_a" }, + "prop_a": { "type": "string" } + }, + "required": ["type", "prop_a"] + }] + } + ]); + + let puncs = json!([{ + "name": "nullable_union_test", + "public": false, + "schemas": [{ + "$id": "nullable_union_test.request", + "type": "object", + "properties": { + "nullable_prop": { + "oneOf": [ + { "$ref": "thing_a" }, + { "type": "null" } + ] + } + }, + "required": ["nullable_prop"] + }] + }]); + 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 6ead439..7a62925 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -929,4 +929,108 @@ 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"); +} + +#[pg_test] +fn test_validate_union_type_matching() { + let cache_result = union_schemas(); + assert_success(&cache_result); + + // 1. Test valid instance with type 'union_a' + let valid_instance_a = json!({ + "union_prop": { + "type": "union_a", + "prop_a": "hello" + } + }); + let result_a = validate_json_schema("union_test.request", jsonb(valid_instance_a)); + assert_success(&result_a); + + // 2. Test valid instance with type 'union_b' + let valid_instance_b = json!({ + "union_prop": { + "type": "union_b", + "prop_b": 123 + } + }); + 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 + let invalid_sub_schema = json!({ + "union_prop": { + "type": "union_a", + "prop_a": 123 // prop_a should be a string + } + }); + 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_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!({ + "union_prop": { + "type": "union_d", // not a valid type in the oneOf + "prop_d": "whatever" + } + }); + 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"); +} + +#[pg_test] +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!({ + "nullable_prop": { + "type": "thing_a", + "prop_a": "hello" + } + }); + let result_obj = validate_json_schema("nullable_union_test.request", jsonb(valid_object)); + assert_success(&result_obj); + + // 2. 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) + 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"); } \ No newline at end of file