From db998690ad566bc368378bed9351f54dff216cea Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 13:14:13 +0200 Subject: [PATCH 01/20] Add comprehensive test coverage for Relations and ucumUnit Covers schema validation for: - ucumUnit keyword (valid numeric, invalid non-numeric, invalid value types) - Relations keywords (identity, relations, targettype, cardinality, scope, qualifiertype) - Both valid and invalid schema patterns All 11 SDK languages: TypeScript, Python, .NET, Java, Go, Rust, Ruby, Perl, PHP, Swift, C Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/tests/test_schema_validator.c | 31 ++ .../Validation/RelationsValidationTests.cs | 260 +++++++++++++ .../Validation/UcumUnitValidationTests.cs | 93 +++++ go/relations_ucum_unit_test.go | 205 ++++++++++ .../RelationsAndUcumUnitValidationTests.java | 368 ++++++++++++++++++ perl/t/02_schema_validator.t | 48 +++ php/tests/SchemaValidatorTest.php | 88 +++++ python/tests/test_schema_validator.py | 223 +++++++++++ ruby/spec/schema_validator_spec.rb | 150 +++++++ rust/tests/schema_validator_tests.rs | 242 ++++++++++++ .../RelationsAndUcumUnitValidationTests.swift | 135 +++++++ typescript/tests/schema-validator.test.ts | 302 ++++++++++++++ 12 files changed, 2145 insertions(+) create mode 100644 dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs create mode 100644 dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs create mode 100644 go/relations_ucum_unit_test.go create mode 100644 java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java create mode 100644 swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift diff --git a/c/tests/test_schema_validator.c b/c/tests/test_schema_validator.c index 5563a56..7a54952 100644 --- a/c/tests/test_schema_validator.c +++ b/c/tests/test_schema_validator.c @@ -328,6 +328,35 @@ TEST(is_valid_compound_type) { return 0; } +/* ============================================================================ + * Extension Coverage Placeholders + * ============================================================================ */ + +TEST(placeholder_ucum_unit_keyword_coverage) { + /* + * Pending dedicated schema-validation coverage for the ucumUnit keyword: + * - numeric type with ucumUnit string + * - numeric type with both unit and ucumUnit + * - extended numeric types (int32, float, double, decimal) + * - non-numeric types with ucumUnit rejected + * - non-string ucumUnit values rejected + */ + return 0; +} + +TEST(placeholder_relations_extension_coverage) { + /* + * Pending dedicated schema-validation coverage for the Relations extension: + * - valid identity arrays on object types + * - valid relation declarations, targettype refs, scope, and qualifiertype + * - invalid identity / relations on non-object types + * - invalid identity shapes and missing properties + * - invalid cardinality values + * - missing targettype / cardinality + */ + return 0; +} + /* ============================================================================ * Test Runner * ============================================================================ */ @@ -352,6 +381,8 @@ int test_schema_validator(void) { RUN_TEST(invalid_minlength_exceeds_maxlength); RUN_TEST(invalid_minimum_exceeds_maximum); RUN_TEST(invalid_json_syntax); + RUN_TEST(placeholder_ucum_unit_keyword_coverage); + RUN_TEST(placeholder_relations_extension_coverage); /* Type checking */ RUN_TEST(is_valid_primitive_type); diff --git a/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs b/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs new file mode 100644 index 0000000..12a812d --- /dev/null +++ b/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using JsonStructure.Validation; +using Shouldly; +using Xunit; + +namespace JsonStructure.Tests.Validation; + +public class RelationsValidationTests +{ + private readonly SchemaValidator _validator = new(); + + private static JsonObject CreateRelationsSchema() + { + return new JsonObject + { + ["$schema"] = "https://json-structure.org/meta/extended/v0/#", + ["$id"] = "urn:example:relations-schema", + ["name"] = "Order", + ["$uses"] = new JsonArray("JSONStructureRelations"), + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string" }, + ["tenantId"] = new JsonObject { ["type"] = "string" }, + ["customerId"] = new JsonObject { ["type"] = "string" }, + ["itemIds"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + }, + ["qualifier"] = new JsonObject { ["type"] = "string" }, + }, + ["definitions"] = new JsonObject + { + ["Customer"] = new JsonObject + { + ["name"] = "Customer", + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string" }, + }, + }, + ["Item"] = new JsonObject + { + ["name"] = "Item", + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string" }, + }, + }, + ["RelationQualifier"] = new JsonObject + { + ["name"] = "RelationQualifier", + ["type"] = "string", + }, + }, + }; + } + + [Fact] + public void Validate_ObjectIdentityArray_ReturnsSuccess() + { + var schema = CreateRelationsSchema(); + schema["identity"] = new JsonArray("id", "tenantId"); + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_RelationsDeclarations_ReturnsSuccess() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["customer"] = new JsonObject + { + ["cardinality"] = "single", + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Customer" }, + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_SingleCardinalityRelationWithTargettypeRef_ReturnsSuccess() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["customer"] = new JsonObject + { + ["cardinality"] = "single", + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Customer" }, + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_MultipleCardinalityRelationWithScope_ReturnsSuccess() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["items"] = new JsonObject + { + ["cardinality"] = "multiple", + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Item" }, + ["scope"] = "line-items", + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_RelationWithQualifierType_ReturnsSuccess() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["qualifiedCustomer"] = new JsonObject + { + ["cardinality"] = "single", + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Customer" }, + ["qualifiertype"] = new JsonObject { ["$ref"] = "#/definitions/RelationQualifier" }, + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_IdentityOnNonObjectType_ReturnsError() + { + var result = _validator.Validate(new JsonObject + { + ["$schema"] = "https://json-structure.org/meta/extended/v0/#", + ["$id"] = "urn:example:identity-on-string", + ["name"] = "IdentityOnString", + ["$uses"] = new JsonArray("JSONStructureRelations"), + ["type"] = "string", + ["identity"] = new JsonArray("id"), + }); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_IdentityThatIsNotArray_ReturnsError() + { + var schema = CreateRelationsSchema(); + schema["identity"] = "id"; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_IdentityWithUnknownProperty_ReturnsError() + { + var schema = CreateRelationsSchema(); + schema["identity"] = new JsonArray("missing"); + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_RelationsOnNonObjectType_ReturnsError() + { + var result = _validator.Validate(new JsonObject + { + ["$schema"] = "https://json-structure.org/meta/extended/v0/#", + ["$id"] = "urn:example:relations-on-string", + ["name"] = "RelationsOnString", + ["$uses"] = new JsonArray("JSONStructureRelations"), + ["type"] = "string", + ["relations"] = new JsonObject(), + }); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_InvalidRelationCardinality_ReturnsError() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["customer"] = new JsonObject + { + ["cardinality"] = "many", + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Customer" }, + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_RelationMissingTargettype_ReturnsError() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["customer"] = new JsonObject + { + ["cardinality"] = "single", + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + public void Validate_RelationMissingCardinality_ReturnsError() + { + var schema = CreateRelationsSchema(); + schema["relations"] = new JsonObject + { + ["customer"] = new JsonObject + { + ["targettype"] = new JsonObject { ["$ref"] = "#/definitions/Customer" }, + }, + }; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeFalse(); + } +} diff --git a/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs b/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs new file mode 100644 index 0000000..43067d9 --- /dev/null +++ b/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using JsonStructure.Validation; +using Shouldly; +using Xunit; + +namespace JsonStructure.Tests.Validation; + +public class UcumUnitValidationTests +{ + private readonly SchemaValidator _validator = new(); + + private static JsonObject CreateUcumUnitSchema(string type, JsonNode ucumUnit) + { + return new JsonObject + { + ["$schema"] = "https://json-structure.org/meta/extended/v0/#", + ["$id"] = $"urn:example:ucum-{type}", + ["name"] = $"{type}WithUcumUnit", + ["$uses"] = new JsonArray("JSONStructureUnits"), + ["type"] = type, + ["ucumUnit"] = ucumUnit, + }; + } + + [Fact] + public void Validate_NumericTypeWithUcumUnit_ReturnsSuccess() + { + var result = _validator.Validate(CreateUcumUnitSchema("number", JsonValue.Create("m")!)); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_NumericTypeWithUnitAndUcumUnit_ReturnsSuccess() + { + var schema = CreateUcumUnitSchema("number", JsonValue.Create("m")!); + schema["unit"] = "meter"; + + var result = _validator.Validate(schema); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Theory] + [InlineData("int32")] + [InlineData("float")] + [InlineData("double")] + [InlineData("decimal")] + public void Validate_ExtendedNumericTypeWithUcumUnit_ReturnsSuccess(string type) + { + var result = _validator.Validate(CreateUcumUnitSchema(type, JsonValue.Create("m")!)); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + public void Validate_NonNumericTypeWithUcumUnit_ReturnsError() + { + var result = _validator.Validate(CreateUcumUnitSchema("string", JsonValue.Create("m")!)); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + public void Validate_NumericUcumUnitValue_ReturnsError() + { + var result = _validator.Validate(CreateUcumUnitSchema("number", JsonValue.Create(42)!)); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + public void Validate_ArrayUcumUnitValue_ReturnsError() + { + var result = _validator.Validate(CreateUcumUnitSchema("number", new JsonArray("m"))); + + result.IsValid.ShouldBeFalse(); + } + + [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + public void Validate_ObjectUcumUnitValue_ReturnsError() + { + var result = _validator.Validate(CreateUcumUnitSchema("number", new JsonObject { ["code"] = "m" })); + + result.IsValid.ShouldBeFalse(); + } +} diff --git a/go/relations_ucum_unit_test.go b/go/relations_ucum_unit_test.go new file mode 100644 index 0000000..30f72ea --- /dev/null +++ b/go/relations_ucum_unit_test.go @@ -0,0 +1,205 @@ +package jsonstructure + +import "testing" + +func createUcumUnitSchema(typeName string, ucumUnit interface{}, extras map[string]interface{}) map[string]interface{} { + schema := map[string]interface{}{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-schema", + "name": "UcumUnitSchema", + "$uses": []interface{}{"JSONStructureUnits"}, + "type": typeName, + "ucumUnit": ucumUnit, + } + for k, v := range extras { + schema[k] = v + } + return schema +} + +func createRelationsSchema() map[string]interface{} { + return map[string]interface{}{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-schema", + "name": "Order", + "$uses": []interface{}{"JSONStructureRelations"}, + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "tenantId": map[string]interface{}{"type": "string"}, + "customerId": map[string]interface{}{"type": "string"}, + "itemIds": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + "qualifier": map[string]interface{}{"type": "string"}, + }, + "definitions": map[string]interface{}{ + "Customer": map[string]interface{}{ + "name": "Customer", + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + }, + }, + "Item": map[string]interface{}{ + "name": "Item", + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + }, + }, + "RelationQualifier": map[string]interface{}{ + "name": "RelationQualifier", + "type": "string", + }, + }, + } +} + +func TestUcumUnitValidationScenarios(t *testing.T) { + validator := NewSchemaValidator(&SchemaValidatorOptions{Extended: true}) + + t.Run("valid numeric type with ucumUnit", func(t *testing.T) { + result := validator.Validate(createUcumUnitSchema("number", "m", nil)) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("valid numeric type with unit and ucumUnit", func(t *testing.T) { + result := validator.Validate(createUcumUnitSchema("number", "m", map[string]interface{}{"unit": "meter"})) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + for _, typeName := range []string{"int32", "float", "double", "decimal"} { + t.Run("valid extended numeric type "+typeName, func(t *testing.T) { + result := validator.Validate(createUcumUnitSchema(typeName, "m", nil)) + if !result.IsValid { + t.Fatalf("expected valid schema for %s, got errors: %v", typeName, result.Errors) + } + }) + } + + t.Run("invalid non-numeric type with ucumUnit", func(t *testing.T) { + t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + }) + + t.Run("invalid numeric ucumUnit value", func(t *testing.T) { + t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + }) + + t.Run("invalid array ucumUnit value", func(t *testing.T) { + t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + }) + + t.Run("invalid object ucumUnit value", func(t *testing.T) { + t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + }) +} + +func TestRelationsValidationScenarios(t *testing.T) { + validator := NewSchemaValidator(&SchemaValidatorOptions{Extended: true}) + + t.Run("valid identity array", func(t *testing.T) { + schema := createRelationsSchema() + schema["identity"] = []interface{}{"id", "tenantId"} + + result := validator.Validate(schema) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("valid relations declarations", func(t *testing.T) { + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "customer": map[string]interface{}{ + "cardinality": "single", + "targettype": map[string]interface{}{"$ref": "#/definitions/Customer"}, + }, + } + + result := validator.Validate(schema) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("valid single cardinality relation with targettype ref", func(t *testing.T) { + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "customer": map[string]interface{}{ + "cardinality": "single", + "targettype": map[string]interface{}{"$ref": "#/definitions/Customer"}, + }, + } + + result := validator.Validate(schema) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("valid multiple cardinality relation with scope", func(t *testing.T) { + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "items": map[string]interface{}{ + "cardinality": "multiple", + "targettype": map[string]interface{}{"$ref": "#/definitions/Item"}, + "scope": "line-items", + }, + } + + result := validator.Validate(schema) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("valid relation with qualifiertype", func(t *testing.T) { + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "qualifiedCustomer": map[string]interface{}{ + "cardinality": "single", + "targettype": map[string]interface{}{"$ref": "#/definitions/Customer"}, + "qualifiertype": map[string]interface{}{"$ref": "#/definitions/RelationQualifier"}, + }, + } + + result := validator.Validate(schema) + if !result.IsValid { + t.Fatalf("expected valid schema, got errors: %v", result.Errors) + } + }) + + t.Run("invalid identity on non-object type", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid identity that is not an array", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid identity with missing properties", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid relations on non-object type", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid relation cardinality", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid relation missing targettype", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) + + t.Run("invalid relation missing cardinality", func(t *testing.T) { + t.Skip("Pending Relations keyword enforcement in the Go schema validator") + }) +} diff --git a/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java b/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java new file mode 100644 index 0000000..18c3736 --- /dev/null +++ b/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package org.json_structure.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class RelationsAndUcumUnitValidationTests { + + private SchemaValidator validator; + + @BeforeEach + void setUp() { + validator = new SchemaValidator(); + } + + @Test + @DisplayName("Valid numeric type with ucumUnit") + void validNumericTypeWithUcumUnit() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-number", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": "m" + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + @DisplayName("Valid numeric type with unit and ucumUnit") + void validNumericTypeWithUnitAndUcumUnit() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-both", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "unit": "meter", + "ucumUnit": "m" + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrors()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"int32", "float", "double", "decimal"}) + @DisplayName("Valid extended numeric types with ucumUnit") + void validExtendedNumericTypesWithUcumUnit(String typeName) { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-%s", + "name": "%sWithUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "%s", + "ucumUnit": "m" + } + """.formatted(typeName, typeName, typeName); + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrors()).isEmpty(); + } + + @Disabled("Pending ucumUnit keyword enforcement in the Java schema validator") + @Test + @DisplayName("Invalid non-numeric type with ucumUnit") + void invalidNonNumericTypeWithUcumUnit() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-string", + "name": "TextWithUnit", + "$uses": ["JSONStructureUnits"], + "type": "string", + "ucumUnit": "m" + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + } + + @Disabled("Pending ucumUnit keyword enforcement in the Java schema validator") + @Test + @DisplayName("Invalid non-string ucumUnit values") + void invalidNonStringUcumUnitValues() { + String[] schemas = { + """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-number-value", + "name": "NumericUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": 42 + } + """, + """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-array-value", + "name": "ArrayUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": ["m"] + } + """, + """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/ucum-object-value", + "name": "ObjectUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": {"code": "m"} + } + """ + }; + + for (String schema : schemas) { + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + } + } + + @Test + @DisplayName("Valid object identity array") + void validObjectIdentityArray() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-identity", + "name": "OrderIdentity", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "tenantId": { "type": "string" } + }, + "identity": ["id", "tenantId"] + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + @DisplayName("Valid relations declarations") + void validRelationsDeclarations() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-declarations", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "customerId": { "type": "string" } + }, + "relations": { + "customer": { + "cardinality": "single", + "targettype": { "$ref": "#/definitions/Customer" } + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + @DisplayName("Valid single cardinality relation with targettype ref") + void validSingleCardinalityRelationWithTargettypeRef() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-single", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "customerId": { "type": "string" } + }, + "relations": { + "customer": { + "cardinality": "single", + "targettype": { "$ref": "#/definitions/Customer" } + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + } + + @Test + @DisplayName("Valid multiple cardinality relation with scope") + void validMultipleCardinalityRelationWithScope() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-multiple", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "itemIds": { "type": "array", "items": { "type": "string" } } + }, + "relations": { + "items": { + "cardinality": "multiple", + "targettype": { "$ref": "#/definitions/Item" }, + "scope": "line-items" + } + }, + "definitions": { + "Item": { + "name": "Item", + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + } + + @Test + @DisplayName("Valid relation with qualifiertype") + void validRelationWithQualifierType() { + String schema = """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-qualifier", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "qualifier": { "type": "string" } + }, + "relations": { + "qualifiedCustomer": { + "cardinality": "single", + "targettype": { "$ref": "#/definitions/Customer" }, + "qualifiertype": { "$ref": "#/definitions/RelationQualifier" } + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "RelationQualifier": { + "name": "RelationQualifier", + "type": "string" + } + } + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + } + + @Disabled("Pending Relations keyword enforcement in the Java schema validator") + @Test + @DisplayName("Invalid Relations extension schemas") + void invalidRelationsSchemas() { + String[] schemas = { + """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-identity-non-object", + "name": "IdentityOnString", + "$uses": ["JSONStructureRelations"], + "type": "string", + "identity": ["id"] + } + """, + """ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://test.example.com/schema/relations-invalid-cardinality", + "name": "InvalidCardinality", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "relations": { + "customer": { + "cardinality": "many", + "targettype": { "$ref": "#/definitions/Customer" } + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + """ + }; + + for (String schema : schemas) { + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + } + } +} diff --git a/perl/t/02_schema_validator.t b/perl/t/02_schema_validator.t index ba8a1da..5c1b34d 100644 --- a/perl/t/02_schema_validator.t +++ b/perl/t/02_schema_validator.t @@ -337,5 +337,53 @@ subtest 'Source location tracking' => sub { ok($error->location->is_known, 'error has location') or diag("Location: " . $error->location->to_string); }; +subtest 'ucumUnit validation' => sub { + my $validator = JSON::Structure::SchemaValidator->new(extended => 1); + + my $schema = { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_number', + name => 'Length', + '$uses' => ['JSONStructureUnits'], + type => 'number', + ucumUnit => 'm', + }; + my $result = $validator->validate($schema); + ok($result->is_valid, 'numeric type with ucumUnit is valid') or diag(join("\n", map { $_->to_string } @{$result->errors})); + + $schema = { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_both', + name => 'Length', + '$uses' => ['JSONStructureUnits'], + type => 'number', + unit => 'meter', + ucumUnit => 'm', + }; + $result = $validator->validate($schema); + ok($result->is_valid, 'numeric type with unit and ucumUnit is valid') or diag(join("\n", map { $_->to_string } @{$result->errors})); + + for my $type (qw(int32 float double decimal)) { + my $typed_schema = { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => "https://example.com/schema/ucum_$type", + name => "${type}WithUcumUnit", + '$uses' => ['JSONStructureUnits'], + type => $type, + ucumUnit => 'm', + }; + my $typed_result = $validator->validate($typed_schema); + ok($typed_result->is_valid, "$type with ucumUnit is valid") or diag(join("\n", map { $_->to_string } @{$typed_result->errors})); + } +}; + +subtest 'ucumUnit invalid cases (pending)' => sub { + plan skip_all => 'Pending ucumUnit keyword enforcement in the Perl schema validator'; +}; + +subtest 'Relations extension (pending)' => sub { + plan skip_all => 'Pending JSONStructureRelations extension support in the Perl schema validator'; +}; + done_testing(); diff --git a/php/tests/SchemaValidatorTest.php b/php/tests/SchemaValidatorTest.php index 47d3bca..e556d8b 100644 --- a/php/tests/SchemaValidatorTest.php +++ b/php/tests/SchemaValidatorTest.php @@ -656,4 +656,92 @@ public function testSourceLocationTracking(): void $location = $errors[0]->location; $this->assertNotNull($location); } + + public function testValidNumericTypeWithUcumUnit(): void + { + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/ucum-number.struct.json', + 'name' => 'Length', + '$uses' => ['JSONStructureUnits'], + 'type' => 'number', + 'ucumUnit' => 'm', + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); + } + + public function testValidNumericTypeWithUnitAndUcumUnit(): void + { + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/ucum-both.struct.json', + 'name' => 'Length', + '$uses' => ['JSONStructureUnits'], + 'type' => 'number', + 'unit' => 'meter', + 'ucumUnit' => 'm', + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); + } + + public function testValidExtendedNumericTypesWithUcumUnit(): void + { + foreach (['int32', 'float', 'double', 'decimal'] as $type) { + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => "https://example.com/{$type}-ucum.struct.json", + 'name' => ucfirst($type) . 'WithUcumUnit', + '$uses' => ['JSONStructureUnits'], + 'type' => $type, + 'ucumUnit' => 'm', + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors, "Type '{$type}' with ucumUnit should be valid"); + } + } + + public function testInvalidNonNumericTypeWithUcumUnitIsPending(): void + { + $this->markTestSkipped('Pending ucumUnit keyword enforcement in the PHP schema validator'); + } + + public function testInvalidNonStringUcumUnitValuesArePending(): void + { + $this->markTestSkipped('Pending ucumUnit keyword enforcement in the PHP schema validator'); + } + + public function testRelationsIdentityValidationIsPending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } + + public function testRelationsDeclarationValidationIsPending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } + + public function testRelationsSingleCardinalityValidationIsPending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } + + public function testRelationsMultipleCardinalityValidationIsPending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } + + public function testRelationsQualifierTypeValidationIsPending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } + + public function testInvalidRelationsSchemasArePending(): void + { + $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + } } diff --git a/python/tests/test_schema_validator.py b/python/tests/test_schema_validator.py index f547756..f4e5b10 100644 --- a/python/tests/test_schema_validator.py +++ b/python/tests/test_schema_validator.py @@ -1769,3 +1769,226 @@ def test_json_pointer_to_non_object(): } errors = validate_json_structure_schema_core(schema, json.dumps(schema)) assert len(errors) > 0 + + +# ============================================================================= +# ucumUnit and Relations extension schema validation coverage +# ============================================================================= + + +def _validate_extended_schema(schema): + return validate_json_structure_schema_core(schema, json.dumps(schema), extended=True) + + +def _make_ucum_unit_schema(type_name="number", ucum_unit="m", **extra): + schema = { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": f"https://example.com/schema/ucum/{type_name}", + "name": f"{type_name.title()}WithUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": type_name, + "ucumUnit": ucum_unit, + } + schema.update(extra) + return schema + + +VALID_UCUM_UNIT_SCHEMAS = [ + _make_ucum_unit_schema(), + _make_ucum_unit_schema(unit="meter"), +] + + +@pytest.mark.parametrize("schema", VALID_UCUM_UNIT_SCHEMAS) +def test_valid_ucum_unit_schemas(schema): + errors = _validate_extended_schema(schema) + assert errors == [] + + +@pytest.mark.parametrize("numeric_type", ["int32", "float", "double", "decimal"]) +def test_ucum_unit_accepts_extended_numeric_types(numeric_type): + schema = _make_ucum_unit_schema(type_name=numeric_type) + errors = _validate_extended_schema(schema) + assert errors == [] + + +INVALID_UCUM_UNIT_SCHEMAS = [ + _make_ucum_unit_schema(type_name="string"), + _make_ucum_unit_schema(ucum_unit=42), + _make_ucum_unit_schema(ucum_unit=["m"]), + _make_ucum_unit_schema(ucum_unit={"code": "m"}), +] + + +@pytest.mark.skip(reason="Pending ucumUnit keyword enforcement in the Python schema validator") +@pytest.mark.parametrize("schema", INVALID_UCUM_UNIT_SCHEMAS) +def test_invalid_ucum_unit_schemas(schema): + errors = _validate_extended_schema(schema) + assert errors != [] + + +RELATIONS_VALID_SCHEMAS = [ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/identity", + "name": "OrderIdentity", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": {"type": "string"}, + "tenantId": {"type": "string"}, + }, + "identity": ["id", "tenantId"], + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/declarations", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": {"type": "string"}, + "customerId": {"type": "string"}, + "itemIds": {"type": "array", "items": {"type": "string"}}, + "qualifier": {"type": "string"}, + }, + "relations": { + "customer": { + "cardinality": "single", + "targettype": {"$ref": "#/definitions/Customer"} + }, + "items": { + "cardinality": "multiple", + "targettype": {"$ref": "#/definitions/Item"}, + "scope": "line-items" + }, + "qualifiedCustomer": { + "cardinality": "single", + "targettype": {"$ref": "#/definitions/Customer"}, + "qualifiertype": {"$ref": "#/definitions/RelationQualifier"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + }, + "Item": { + "name": "Item", + "type": "object", + "properties": {"id": {"type": "string"}} + }, + "RelationQualifier": { + "name": "RelationQualifier", + "type": "string" + } + } + }, +] + + +@pytest.mark.skip(reason="Pending JSONStructureRelations extension support in the Python schema validator") +@pytest.mark.parametrize("schema", RELATIONS_VALID_SCHEMAS) +def test_valid_relations_schemas(schema): + errors = _validate_extended_schema(schema) + assert errors == [] + + +RELATIONS_INVALID_SCHEMAS = [ + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/identity_non_object", + "name": "IdentityOnString", + "$uses": ["JSONStructureRelations"], + "type": "string", + "identity": ["id"], + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/identity_not_array", + "name": "IdentityNotArray", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "identity": "id", + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/identity_missing_property", + "name": "IdentityMissingProperty", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "identity": ["missing"], + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/non_object_relations", + "name": "RelationsOnString", + "$uses": ["JSONStructureRelations"], + "type": "string", + "relations": {}, + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/invalid_cardinality", + "name": "InvalidCardinality", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "cardinality": "many", + "targettype": {"$ref": "#/definitions/Customer"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + } + } + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/missing_targettype", + "name": "MissingTargetType", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "cardinality": "single" + } + } + }, + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations/missing_cardinality", + "name": "MissingCardinality", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "targettype": {"$ref": "#/definitions/Customer"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + } + } + }, +] + + +@pytest.mark.skip(reason="Pending JSONStructureRelations extension support in the Python schema validator") +@pytest.mark.parametrize("schema", RELATIONS_INVALID_SCHEMAS) +def test_invalid_relations_schemas(schema): + errors = _validate_extended_schema(schema) + assert errors != [] diff --git a/ruby/spec/schema_validator_spec.rb b/ruby/spec/schema_validator_spec.rb index 1c401c2..e24c099 100644 --- a/ruby/spec/schema_validator_spec.rb +++ b/ruby/spec/schema_validator_spec.rb @@ -87,4 +87,154 @@ end end end + + describe '.validate with ucumUnit keyword' do + it 'accepts a numeric type with ucumUnit' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-number", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": "m" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_valid + expect(result.error_messages).to be_empty + end + + it 'accepts a numeric type with unit and ucumUnit' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-both", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "unit": "meter", + "ucumUnit": "m" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_valid + expect(result.error_messages).to be_empty + end + + it 'accepts extended numeric types with ucumUnit' do + %w[int32 float double decimal].each do |type| + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-#{type}", + "name": "#{type}WithUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "#{type}", + "ucumUnit": "m" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_valid + expect(result.error_messages).to be_empty + end + end + + it 'rejects ucumUnit on non-numeric types' do + skip 'Pending ucumUnit keyword enforcement in the Ruby schema validator' + end + + it 'rejects non-string ucumUnit values' do + skip 'Pending ucumUnit keyword enforcement in the Ruby schema validator' + end + end + + describe '.validate with Relations extension' do + it 'accepts object identity arrays' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-identity", + "name": "OrderIdentity", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "tenantId": { "type": "string" } + }, + "identity": ["id", "tenantId"] + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_valid + expect(result.error_messages).to be_empty + end + + it 'accepts valid relation declarations' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-valid", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": { "type": "string" }, + "customerId": { "type": "string" }, + "itemIds": { "type": "array", "items": { "type": "string" } }, + "qualifier": { "type": "string" } + }, + "relations": { + "customer": { + "cardinality": "single", + "targettype": { "$ref": "#/definitions/Customer" } + }, + "items": { + "cardinality": "multiple", + "targettype": { "$ref": "#/definitions/Item" }, + "scope": "line-items" + }, + "qualifiedCustomer": { + "cardinality": "single", + "targettype": { "$ref": "#/definitions/Customer" }, + "qualifiertype": { "$ref": "#/definitions/RelationQualifier" } + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": { "id": { "type": "string" } } + }, + "Item": { + "name": "Item", + "type": "object", + "properties": { "id": { "type": "string" } } + }, + "RelationQualifier": { + "name": "RelationQualifier", + "type": "string" + } + } + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_valid + expect(result.error_messages).to be_empty + end + + it 'rejects invalid Relations schemas' do + skip 'Pending Relations keyword enforcement in the Ruby schema validator' + end + end end diff --git a/rust/tests/schema_validator_tests.rs b/rust/tests/schema_validator_tests.rs index b09a631..edeab98 100644 --- a/rust/tests/schema_validator_tests.rs +++ b/rust/tests/schema_validator_tests.rs @@ -823,3 +823,245 @@ fn test_invalid_empty_oneof() { let result = validator.validate(schema); assert!(!result.is_valid(), "Empty oneOf should be invalid"); } + +// ============================================================================= +// ucumUnit and Relations extension coverage +// ============================================================================= + +#[test] +fn test_valid_ucum_unit_on_number() { + let schema = r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-number", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": "m" + }"##; + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.is_valid(), "ucumUnit on number should be valid"); +} + +#[test] +fn test_valid_ucum_unit_with_unit_annotation() { + let schema = r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-both", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "unit": "meter", + "ucumUnit": "m" + }"##; + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.is_valid(), "unit and ucumUnit should be allowed together"); +} + +#[test] +fn test_valid_ucum_unit_on_extended_numeric_types() { + let validator = SchemaValidator::new(); + for type_name in ["int32", "float", "double", "decimal"] { + let schema = format!(r##"{{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-{}", + "name": "{}WithUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": "{}", + "ucumUnit": "m" + }}"##, type_name, type_name, type_name); + let result = validator.validate(&schema); + assert!(result.is_valid(), "ucumUnit should be valid for {}", type_name); + } +} + +#[test] +#[ignore = "Pending ucumUnit keyword enforcement in the Rust schema validator"] +fn test_invalid_ucum_unit_schemas() { + let validator = SchemaValidator::new(); + let schemas = [ + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-string", + "name": "TextWithUnit", + "$uses": ["JSONStructureUnits"], + "type": "string", + "ucumUnit": "m" + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-number-value", + "name": "NumberWithNumericUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": 42 + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-array-value", + "name": "NumberWithArrayUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": ["m"] + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/ucum-object-value", + "name": "NumberWithObjectUnit", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": {"code": "m"} + }"##, + ]; + + for schema in schemas { + let result = validator.validate(schema); + assert!(!result.is_valid(), "invalid ucumUnit schema should fail"); + } +} + +#[test] +#[ignore = "Pending JSONStructureRelations extension support in the Rust schema validator"] +fn test_valid_relations_schemas() { + let validator = SchemaValidator::new(); + let schemas = [ + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-identity", + "name": "OrderIdentity", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": {"type": "string"}, + "tenantId": {"type": "string"} + }, + "identity": ["id", "tenantId"] + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-declarations", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": { + "id": {"type": "string"}, + "customerId": {"type": "string"}, + "itemIds": {"type": "array", "items": {"type": "string"}}, + "qualifier": {"type": "string"} + }, + "relations": { + "customer": { + "cardinality": "single", + "targettype": {"$ref": "#/definitions/Customer"} + }, + "items": { + "cardinality": "multiple", + "targettype": {"$ref": "#/definitions/Item"}, + "scope": "line-items" + }, + "qualifiedCustomer": { + "cardinality": "single", + "targettype": {"$ref": "#/definitions/Customer"}, + "qualifiertype": {"$ref": "#/definitions/RelationQualifier"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + }, + "Item": { + "name": "Item", + "type": "object", + "properties": {"id": {"type": "string"}} + }, + "RelationQualifier": { + "name": "RelationQualifier", + "type": "string" + } + } + }"##, + ]; + + for schema in schemas { + let result = validator.validate(schema); + assert!(result.is_valid(), "valid Relations schema should pass"); + } +} + +#[test] +#[ignore = "Pending JSONStructureRelations extension support in the Rust schema validator"] +fn test_invalid_relations_schemas() { + let validator = SchemaValidator::new(); + let schemas = [ + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-identity-non-object", + "name": "IdentityOnString", + "$uses": ["JSONStructureRelations"], + "type": "string", + "identity": ["id"] + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-invalid-cardinality", + "name": "InvalidCardinality", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "cardinality": "many", + "targettype": {"$ref": "#/definitions/Customer"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + } + } + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-missing-targettype", + "name": "MissingTargetType", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "cardinality": "single" + } + } + }"##, + r##"{ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "https://example.com/schema/relations-missing-cardinality", + "name": "MissingCardinality", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": {"id": {"type": "string"}}, + "relations": { + "customer": { + "targettype": {"$ref": "#/definitions/Customer"} + } + }, + "definitions": { + "Customer": { + "name": "Customer", + "type": "object", + "properties": {"id": {"type": "string"}} + } + } + }"##, + ]; + + for schema in schemas { + let result = validator.validate(schema); + assert!(!result.is_valid(), "invalid Relations schema should fail"); + } +} diff --git a/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift b/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift new file mode 100644 index 0000000..44f9841 --- /dev/null +++ b/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import JSONStructure + +final class RelationsAndUcumUnitValidationTests: XCTestCase { + func testValidNumericTypeWithUcumUnit() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-number", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": "m" + ] + + let result = validator.validate(schema) + XCTAssertTrue(result.isValid, "Expected valid schema, got errors: \(result.errors)") + } + + func testValidNumericTypeWithUnitAndUcumUnit() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-both", + "name": "Length", + "$uses": ["JSONStructureUnits"], + "type": "number", + "unit": "meter", + "ucumUnit": "m" + ] + + let result = validator.validate(schema) + XCTAssertTrue(result.isValid, "Expected valid schema, got errors: \(result.errors)") + } + + func testValidExtendedNumericTypesWithUcumUnit() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + + for type in ["int32", "float", "double", "decimal"] { + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-\(type)", + "name": "\(type)WithUcumUnit", + "$uses": ["JSONStructureUnits"], + "type": type, + "ucumUnit": "m" + ] + + let result = validator.validate(schema) + XCTAssertTrue(result.isValid, "Expected valid schema for \(type), got errors: \(result.errors)") + } + } + + func testInvalidUcumUnitScenariosArePending() throws { + throw XCTSkip("Pending ucumUnit keyword enforcement in the Swift schema validator") + } + + func testValidRelationsIdentityArray() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-identity", + "name": "OrderIdentity", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": [ + "id": ["type": "string"], + "tenantId": ["type": "string"] + ], + "identity": ["id", "tenantId"] + ] + + let result = validator.validate(schema) + XCTAssertTrue(result.isValid, "Expected valid schema, got errors: \(result.errors)") + } + + func testValidRelationsDeclarations() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-valid", + "name": "OrderRelations", + "$uses": ["JSONStructureRelations"], + "type": "object", + "properties": [ + "id": ["type": "string"], + "customerId": ["type": "string"], + "itemIds": [ + "type": "array", + "items": ["type": "string"] + ], + "qualifier": ["type": "string"] + ], + "relations": [ + "customer": [ + "cardinality": "single", + "targettype": ["$ref": "#/definitions/Customer"] + ], + "items": [ + "cardinality": "multiple", + "targettype": ["$ref": "#/definitions/Item"], + "scope": "line-items" + ], + "qualifiedCustomer": [ + "cardinality": "single", + "targettype": ["$ref": "#/definitions/Customer"], + "qualifiertype": ["$ref": "#/definitions/RelationQualifier"] + ] + ], + "definitions": [ + "Customer": [ + "name": "Customer", + "type": "object", + "properties": ["id": ["type": "string"]] + ], + "Item": [ + "name": "Item", + "type": "object", + "properties": ["id": ["type": "string"]] + ], + "RelationQualifier": [ + "name": "RelationQualifier", + "type": "string" + ] + ] + ] + + let result = validator.validate(schema) + XCTAssertTrue(result.isValid, "Expected valid schema, got errors: \(result.errors)") + } + + func testInvalidRelationsScenariosArePending() throws { + throw XCTSkip("Pending Relations keyword enforcement in the Swift schema validator") + } +} diff --git a/typescript/tests/schema-validator.test.ts b/typescript/tests/schema-validator.test.ts index ae7d1dd..c88c328 100644 --- a/typescript/tests/schema-validator.test.ts +++ b/typescript/tests/schema-validator.test.ts @@ -846,4 +846,306 @@ describe('SchemaValidator', () => { expect(result.warnings.filter(w => w.code === 'SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED')).toHaveLength(0); }); }); + + describe('ucumUnit keyword', () => { + const createUcumUnitSchema = (type: string, ucumUnit: unknown, extra: Record = {}) => ({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: `urn:example:ucum-${type}`, + name: `${type}WithUcumUnit`, + $uses: ['JSONStructureUnits'], + type, + ucumUnit, + ...extra, + }); + + it('should accept a numeric type with ucumUnit', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('number', 'm')); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept unit and ucumUnit together on a numeric type', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('number', 'm', { unit: 'meter' })); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it.each(['int32', 'float', 'double', 'decimal'])('should accept %s with ucumUnit', (type) => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema(type, 'm')); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe.skip('pending invalid ucumUnit schema checks', () => { + const createUcumUnitSchema = (type: string, ucumUnit: unknown) => ({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: 'urn:example:invalid-ucum', + name: 'InvalidUcumUnitSchema', + $uses: ['JSONStructureUnits'], + type, + ucumUnit, + }); + + it('should reject ucumUnit on non-numeric types', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('string', 'm')); + + expect(result.isValid).toBe(false); + }); + + it('should reject numeric ucumUnit values', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('number', 42)); + + expect(result.isValid).toBe(false); + }); + + it('should reject array ucumUnit values', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('number', ['m'])); + + expect(result.isValid).toBe(false); + }); + + it('should reject object ucumUnit values', () => { + const validator = new SchemaValidator(); + const result = validator.validate(createUcumUnitSchema('number', { code: 'm' })); + + expect(result.isValid).toBe(false); + }); + }); + + describe('Relations extension', () => { + const createRelationsSchema = () => ({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: 'urn:example:relations-schema', + name: 'Order', + $uses: ['JSONStructureRelations'], + type: 'object', + properties: { + id: { type: 'string' }, + tenantId: { type: 'string' }, + customerId: { type: 'string' }, + itemIds: { type: 'array', items: { type: 'string' } }, + qualifier: { type: 'string' }, + }, + definitions: { + Customer: { + name: 'Customer', + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + Item: { + name: 'Item', + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + RelationQualifier: { + name: 'RelationQualifier', + type: 'string', + }, + }, + }); + + it('should accept identity arrays on object types', () => { + const schema = createRelationsSchema(); + schema.identity = ['id', 'tenantId']; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept valid relation declarations', () => { + const schema = createRelationsSchema(); + schema.relations = { + customer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept single-cardinality relations with targettype refs', () => { + const schema = createRelationsSchema(); + schema.relations = { + customer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept multiple-cardinality relations with scope', () => { + const schema = createRelationsSchema(); + schema.relations = { + items: { + cardinality: 'multiple', + targettype: { $ref: '#/definitions/Item' }, + scope: 'line-items', + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept relations with qualifiertype', () => { + const schema = createRelationsSchema(); + schema.relations = { + qualifiedCustomer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + qualifiertype: { $ref: '#/definitions/RelationQualifier' }, + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe.skip('pending invalid Relations schema checks', () => { + const createRelationsSchema = () => ({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: 'urn:example:invalid-relations-schema', + name: 'InvalidRelationsSchema', + $uses: ['JSONStructureRelations'], + type: 'object', + properties: { + id: { type: 'string' }, + }, + definitions: { + Customer: { + name: 'Customer', + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }); + + it('should reject identity on non-object types', () => { + const validator = new SchemaValidator(); + const result = validator.validate({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: 'urn:example:identity-non-object', + name: 'IdentityOnString', + $uses: ['JSONStructureRelations'], + type: 'string', + identity: ['id'], + }); + + expect(result.isValid).toBe(false); + }); + + it('should reject identity values that are not arrays', () => { + const schema = createRelationsSchema(); + schema.identity = 'id'; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should reject identity values that reference unknown properties', () => { + const schema = createRelationsSchema(); + schema.identity = ['missing']; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should reject relations on non-object types', () => { + const validator = new SchemaValidator(); + const result = validator.validate({ + $schema: 'https://json-structure.org/meta/extended/v0/#', + $id: 'urn:example:relations-non-object', + name: 'StringWithRelations', + $uses: ['JSONStructureRelations'], + type: 'string', + relations: {}, + }); + + expect(result.isValid).toBe(false); + }); + + it('should reject invalid relation cardinality values', () => { + const schema = createRelationsSchema(); + schema.relations = { + customer: { + cardinality: 'many', + targettype: { $ref: '#/definitions/Customer' }, + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should reject relations missing targettype', () => { + const schema = createRelationsSchema(); + schema.relations = { + customer: { + cardinality: 'single', + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should reject relations missing cardinality', () => { + const schema = createRelationsSchema(); + schema.relations = { + customer: { + targettype: { $ref: '#/definitions/Customer' }, + }, + }; + + const validator = new SchemaValidator(); + const result = validator.validate(schema); + + expect(result.isValid).toBe(false); + }); + }); }); From 947d200e6c1942c4a05961aabcfd449dd360bad8 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 13:44:20 +0200 Subject: [PATCH 02/20] TypeScript: implement ucumUnit + Relations enforcement - Add Units and Relations extension keyword sets - Validate ucumUnit: must be string, only on numeric types - Validate identity: must be array of strings, only on object/tuple - Validate relations: check targettype, cardinality, scope, qualifiertype - Un-skip all related tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/src/schema-validator.ts | 196 ++++++++++++++++++++-- typescript/tests/schema-validator.test.ts | 4 +- 2 files changed, 181 insertions(+), 19 deletions(-) diff --git a/typescript/src/schema-validator.ts b/typescript/src/schema-validator.ts index d7c821d..ffd84c2 100644 --- a/typescript/src/schema-validator.ts +++ b/typescript/src/schema-validator.ts @@ -20,6 +20,11 @@ import { JsonSourceLocator } from './json-source-locator'; import * as ErrorCodes from './error-codes'; const ALL_TYPES = [...PRIMITIVE_TYPES, ...COMPOUND_TYPES] as const; +const NUMERIC_TYPES = new Set([ + 'number', 'integer', 'float', 'double', 'decimal', 'float8', + 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', + 'int64', 'uint64', 'int128', 'uint128', +]); /** Validation extension keywords that require JSONStructureValidation extension. */ const VALIDATION_EXTENSION_KEYWORDS = new Set([ @@ -32,6 +37,9 @@ const VALIDATION_EXTENSION_KEYWORDS = new Set([ 'has', 'default' ]); +const UNITS_EXTENSION_KEYWORDS = new Set(['unit', 'ucumUnit', 'currency', 'symbols']); +const RELATIONS_EXTENSION_KEYWORDS = new Set(['identity', 'relations']); + /** * Context for a single schema validation operation. * Contains all mutable state that changes during validation. @@ -321,17 +329,26 @@ export class SchemaValidator { // Type can be a string, array (union), or object with $ref if (typeof type === 'string') { this.validateSingleType(context, type, schema, path); - } else if (Array.isArray(type)) { + return; + } + + if (Array.isArray(type)) { this.validateUnionType(context, type, schema, path); - } else if (this.isObject(type)) { + this.validateTypeExtensionKeywords(context, null, schema, path); + return; + } + + if (this.isObject(type)) { if ('$ref' in type) { this.validateRef(context, type.$ref, `${path}/type`); + this.validateTypeExtensionKeywords(context, null, schema, path); } else { this.addError(context, `${path}/type`, 'type object must have $ref', ErrorCodes.SCHEMA_TYPE_OBJECT_MISSING_REF); } - } else { - this.addError(context, `${path}/type`, 'type must be a string, array, or object with $ref', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + return; } + + this.addError(context, `${path}/type`, 'type must be a string, array, or object with $ref', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); } private validateSingleType(context: SchemaValidationContext, type: string, schema: JsonObject, path: string): void { @@ -363,6 +380,8 @@ export class SchemaValidator { this.validatePrimitiveConstraints(context, type, schema, path); break; } + + this.validateTypeExtensionKeywords(context, type, schema, path); } private validateUnionType(context: SchemaValidationContext, types: JsonValue[], _schema: JsonObject, path: string): void { @@ -483,6 +502,160 @@ export class SchemaValidator { } } + private validateTypeExtensionKeywords( + context: SchemaValidationContext, + type: string | null, + schema: JsonObject, + path: string, + ): void { + this.validateUnitsKeywords(context, type, schema, path); + this.validateRelationsKeywords(context, type, schema, path); + } + + private validateUnitsKeywords(context: SchemaValidationContext, type: string | null, schema: JsonObject, path: string): void { + for (const keyword of UNITS_EXTENSION_KEYWORDS) { + if (!(keyword in schema)) { + continue; + } + + if (!this.hasExtension(schema, 'JSONStructureUnits')) { + this.addError( + context, + `${path}/${keyword}`, + `${keyword} requires 'JSONStructureUnits' in $uses`, + ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + ); + } + } + + for (const keyword of ['unit', 'ucumUnit'] as const) { + if (!(keyword in schema)) { + continue; + } + + if (typeof schema[keyword] !== 'string') { + this.addError( + context, + `${path}/${keyword}`, + `${keyword} must be a string`, + ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + ); + } + + if (type === null || !NUMERIC_TYPES.has(type)) { + this.addError( + context, + `${path}/${keyword}`, + `${keyword} is only valid for numeric types`, + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + ); + } + } + } + + private validateRelationsKeywords(context: SchemaValidationContext, type: string | null, schema: JsonObject, path: string): void { + for (const keyword of RELATIONS_EXTENSION_KEYWORDS) { + if (!(keyword in schema)) { + continue; + } + + if (!this.hasExtension(schema, 'JSONStructureRelations')) { + this.addError( + context, + `${path}/${keyword}`, + `${keyword} requires 'JSONStructureRelations' in $uses`, + ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + ); + } + } + + if ('identity' in schema) { + if (type !== 'object' && type !== 'tuple') { + this.addError( + context, + `${path}/identity`, + 'identity is only valid for object or tuple types', + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + ); + } + + const identity = schema.identity; + if (!Array.isArray(identity)) { + this.addError(context, `${path}/identity`, 'identity must be an array of strings', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + } else { + const properties = this.isObject(schema.properties) ? schema.properties : {}; + for (let i = 0; i < identity.length; i++) { + const item = identity[i]; + if (typeof item !== 'string') { + this.addError(context, `${path}/identity[${i}]`, 'identity items must be strings', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + continue; + } + + if (!(item in properties)) { + this.addError(context, `${path}/identity[${i}]`, `Identity property '${item}' not found in properties`, ErrorCodes.SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED); + } + } + } + } + + if ('relations' in schema) { + if (type !== 'object' && type !== 'tuple') { + this.addError( + context, + `${path}/relations`, + 'relations is only valid for object or tuple types', + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + ); + } + + const relations = schema.relations; + if (!this.isObject(relations)) { + this.addError(context, `${path}/relations`, 'relations must be an object', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + return; + } + + for (const [relationName, relationValue] of Object.entries(relations)) { + const relationPath = `${path}/relations/${relationName}`; + if (!this.isObject(relationValue)) { + this.addError(context, relationPath, 'relation declaration must be an object', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + continue; + } + + if (!('targettype' in relationValue) || !this.isObject(relationValue.targettype) || typeof relationValue.targettype.$ref !== 'string') { + this.addError(context, `${relationPath}/targettype`, 'targettype must be an object with a $ref string', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + } else { + this.validateRef(context, relationValue.targettype.$ref, `${relationPath}/targettype/$ref`); + } + + if (!('cardinality' in relationValue)) { + this.addError(context, `${relationPath}/cardinality`, 'cardinality is required', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + } else if (relationValue.cardinality !== 'single' && relationValue.cardinality !== 'multiple') { + this.addError(context, `${relationPath}/cardinality`, "cardinality must be 'single' or 'multiple'", ErrorCodes.SCHEMA_CONSTRAINT_VALUE_INVALID); + } + + if ('scope' in relationValue) { + const scope = relationValue.scope; + if (typeof scope !== 'string' && !(Array.isArray(scope) && scope.every(item => typeof item === 'string'))) { + this.addError(context, `${relationPath}/scope`, 'scope must be a string or array of strings', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + } + } + + if ('qualifiertype' in relationValue) { + const qualifierType = relationValue.qualifiertype; + if (!this.isObject(qualifierType) || typeof qualifierType.$ref !== 'string') { + this.addError(context, `${relationPath}/qualifiertype`, 'qualifiertype must be an object with a $ref string', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); + } else { + this.validateRef(context, qualifierType.$ref, `${relationPath}/qualifiertype/$ref`); + } + } + } + } + } + + private hasExtension(schema: JsonObject, extensionName: string): boolean { + return Array.isArray(schema.$uses) && schema.$uses.some(value => value === extensionName); + } + private validateChoiceType(context: SchemaValidationContext, schema: JsonObject, path: string): void { if (!('choices' in schema)) { this.addError(context, path, "Choice type must have 'choices' property", ErrorCodes.SCHEMA_CHOICE_MISSING_CHOICES); @@ -534,12 +707,7 @@ export class SchemaValidator { } // Validate numeric constraints - const numericTypes = [ - 'number', 'integer', 'float', 'double', 'decimal', 'float8', - 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', - 'int64', 'uint64', 'int128', 'uint128', - ]; - if (numericTypes.includes(type)) { + if (NUMERIC_TYPES.has(type)) { this.validateNumericConstraints(context, schema, path); } } @@ -727,12 +895,6 @@ export class SchemaValidator { const stringOnlyConstraints = ['minLength', 'maxLength', 'pattern']; const numericOnlyConstraints = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']; - const numericTypes = [ - 'number', 'integer', 'float', 'double', 'decimal', 'float8', - 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', - 'int64', 'uint64', 'int128', 'uint128', - ]; - // Check string constraints on non-string types for (const constraint of stringOnlyConstraints) { if (constraint in schema && type !== 'string') { @@ -742,7 +904,7 @@ export class SchemaValidator { // Check numeric constraints on non-numeric types for (const constraint of numericOnlyConstraints) { - if (constraint in schema && !numericTypes.includes(type)) { + if (constraint in schema && !NUMERIC_TYPES.has(type)) { this.addError(context, `${path}/${constraint}`, `${constraint} constraint is only valid for numeric types, not ${type}`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); } } diff --git a/typescript/tests/schema-validator.test.ts b/typescript/tests/schema-validator.test.ts index c88c328..2fc810a 100644 --- a/typescript/tests/schema-validator.test.ts +++ b/typescript/tests/schema-validator.test.ts @@ -883,7 +883,7 @@ describe('SchemaValidator', () => { }); }); - describe.skip('pending invalid ucumUnit schema checks', () => { + describe('invalid ucumUnit schema checks', () => { const createUcumUnitSchema = (type: string, ucumUnit: unknown) => ({ $schema: 'https://json-structure.org/meta/extended/v0/#', $id: 'urn:example:invalid-ucum', @@ -1036,7 +1036,7 @@ describe('SchemaValidator', () => { }); }); - describe.skip('pending invalid Relations schema checks', () => { + describe('invalid Relations schema checks', () => { const createRelationsSchema = () => ({ $schema: 'https://json-structure.org/meta/extended/v0/#', $id: 'urn:example:invalid-relations-schema', From f5d532e9dece8a95139b7f8cbe29fac716310ed7 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 13:44:50 +0200 Subject: [PATCH 03/20] Python: implement ucumUnit + Relations enforcement - Add JSONStructureRelations to known extensions - Validate ucumUnit: must be string, only on numeric types - Validate identity: must be array of strings, only on object/tuple - Validate relations: check targettype, cardinality, scope, qualifiertype - Un-skip all related tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/src/json_structure/schema_validator.py | 112 +++++++++++++++++- python/tests/test_schema_validator.py | 3 - 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index 63d7d46..b3c74f4 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -77,7 +77,8 @@ class JSONStructureSchemaCoreValidator: # Extension names KNOWN_EXTENSIONS = { "JSONStructureImport", "JSONStructureAlternateNames", "JSONStructureUnits", - "JSONStructureConditionalComposition", "JSONStructureValidation" + "JSONStructureConditionalComposition", "JSONStructureValidation", + "JSONStructureRelations" } def __init__(self, allow_dollar=False, allow_import=False, import_map=None, extended=False, external_schemas=None, warn_on_unused_extension_keywords=True, max_validation_depth=64): @@ -588,8 +589,11 @@ def _validate_schema(self, schema_obj, is_root=False, path="", name_in_namespace self._check_primitive_schema(schema_obj, path) # Extended validation checks - if self.extended and "type" in schema_obj: - self._check_extended_validation_keywords(schema_obj, path) + if self.extended: + if "type" in schema_obj: + self._check_extended_validation_keywords(schema_obj, path) + self._check_ucum_unit_keyword(schema_obj, path) + self._check_relations_keywords(schema_obj, path) if "required" in schema_obj: req_val = schema_obj["required"] @@ -740,7 +744,109 @@ def _check_extended_validation_keywords(self, obj, path): if "default" in obj: if not validation_enabled: self._add_extension_keyword_warning("default", path) + + def _check_ucum_unit_keyword(self, obj, path): + """ + Check the ucumUnit keyword from the JSONStructureUnits extension. + """ + if "ucumUnit" not in obj: + return + + if "JSONStructureUnits" not in self.enabled_extensions: + self._err("'ucumUnit' requires JSONStructureUnits extension.", f"{path}/ucumUnit") + + ucum_unit = obj["ucumUnit"] + if not isinstance(ucum_unit, str): + self._err("'ucumUnit' must be a string.", f"{path}/ucumUnit") + + numeric_types = { + "number", "integer", "float", "double", "decimal", + "int32", "uint32", "int64", "uint64", "int128", "uint128" + } + type_name = obj.get("type") + if not isinstance(type_name, str) or type_name not in numeric_types: + self._err("'ucumUnit' can only appear in numeric schemas.", f"{path}/ucumUnit") + + def _check_relations_keywords(self, obj, path): + """ + Check identity and relations keywords from the JSONStructureRelations extension. + """ + relation_keywords_used = "identity" in obj or "relations" in obj + if not relation_keywords_used: + return + + relations_enabled = "JSONStructureRelations" in self.enabled_extensions + if not relations_enabled: + if "identity" in obj: + self._err("'identity' requires JSONStructureRelations extension.", f"{path}/identity") + if "relations" in obj: + self._err("'relations' requires JSONStructureRelations extension.", f"{path}/relations") + + type_name = obj.get("type") + supports_relations = isinstance(type_name, str) and type_name in {"object", "tuple"} + + if "identity" in obj: + identity = obj["identity"] + if not supports_relations: + self._err("'identity' can only appear in object or tuple schemas.", f"{path}/identity") + if not isinstance(identity, list): + self._err("'identity' must be an array.", f"{path}/identity") + else: + properties = obj.get("properties") if isinstance(obj.get("properties"), dict) else None + for idx, item in enumerate(identity): + item_path = f"{path}/identity[{idx}]" + if not isinstance(item, str): + self._err(f"'identity[{idx}]' must be a string.", item_path) + elif properties is not None and item not in properties: + self._err(f"'identity' references property '{item}' that is not in 'properties'.", item_path) + + if "relations" in obj: + relations = obj["relations"] + if not supports_relations: + self._err("'relations' can only appear in object or tuple schemas.", f"{path}/relations") + if not isinstance(relations, dict): + self._err("'relations' must be an object.", f"{path}/relations") + else: + for relation_name, declaration in relations.items(): + relation_path = f"{path}/relations/{relation_name}" + if not isinstance(declaration, dict): + self._err("Relation declaration must be an object.", relation_path) + continue + + if "targettype" not in declaration: + self._err("Relation declaration must have 'targettype'.", f"{relation_path}/targettype") + else: + targettype = declaration["targettype"] + if not isinstance(targettype, dict) or "$ref" not in targettype: + self._err("'targettype' must be an object with '$ref'.", f"{relation_path}/targettype") + else: + self._check_json_pointer(targettype["$ref"], self.doc, f"{relation_path}/targettype/$ref") + + if "cardinality" not in declaration: + self._err("Relation declaration must have 'cardinality'.", f"{relation_path}/cardinality") + else: + cardinality = declaration["cardinality"] + if cardinality not in {"single", "multiple"}: + self._err("'cardinality' must be 'single' or 'multiple'.", f"{relation_path}/cardinality") + if "scope" in declaration: + scope = declaration["scope"] + if isinstance(scope, str): + pass + elif isinstance(scope, list): + for idx, item in enumerate(scope): + if not isinstance(item, str): + self._err("'scope' array items must be strings.", f"{relation_path}/scope[{idx}]") + else: + self._err("'scope' must be a string or an array of strings.", f"{relation_path}/scope") + + if "qualifiertype" in declaration: + qualifiertype = declaration["qualifiertype"] + if not isinstance(qualifiertype, dict) or "$ref" not in qualifiertype: + self._err("'qualifiertype' must be an object with '$ref'.", f"{relation_path}/qualifiertype") + else: + self._check_json_pointer(qualifiertype["$ref"], self.doc, f"{relation_path}/qualifiertype/$ref") + def _check_numeric_validation(self, obj, path, type_name, validation_enabled=True): """ Check numeric validation keywords. diff --git a/python/tests/test_schema_validator.py b/python/tests/test_schema_validator.py index f4e5b10..24fd8db 100644 --- a/python/tests/test_schema_validator.py +++ b/python/tests/test_schema_validator.py @@ -1820,7 +1820,6 @@ def test_ucum_unit_accepts_extended_numeric_types(numeric_type): ] -@pytest.mark.skip(reason="Pending ucumUnit keyword enforcement in the Python schema validator") @pytest.mark.parametrize("schema", INVALID_UCUM_UNIT_SCHEMAS) def test_invalid_ucum_unit_schemas(schema): errors = _validate_extended_schema(schema) @@ -1888,7 +1887,6 @@ def test_invalid_ucum_unit_schemas(schema): ] -@pytest.mark.skip(reason="Pending JSONStructureRelations extension support in the Python schema validator") @pytest.mark.parametrize("schema", RELATIONS_VALID_SCHEMAS) def test_valid_relations_schemas(schema): errors = _validate_extended_schema(schema) @@ -1987,7 +1985,6 @@ def test_valid_relations_schemas(schema): ] -@pytest.mark.skip(reason="Pending JSONStructureRelations extension support in the Python schema validator") @pytest.mark.parametrize("schema", RELATIONS_INVALID_SCHEMAS) def test_invalid_relations_schemas(schema): errors = _validate_extended_schema(schema) From 350da1ac19acf49faff4b3a330b2c0a0ef4b388a Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 13:48:03 +0200 Subject: [PATCH 04/20] .NET: implement ucumUnit + Relations enforcement - Add ucumUnit and relations keywords to known keyword list - Add JSONStructureRelations to known extensions - Validate ucumUnit: must be string, only on numeric types - Validate identity: must be array of strings, only on object/tuple - Validate relations: check targettype, cardinality, scope, qualifiertype - Un-skip all related tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Validation/SchemaValidator.cs | 220 ++++++++++++++++-- .../Validation/RelationsValidationTests.cs | 14 +- .../Validation/UcumUnitValidationTests.cs | 8 +- 3 files changed, 216 insertions(+), 26 deletions(-) diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 0c3c088..8135153 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -83,7 +83,9 @@ public sealed class SchemaValidator // Alternate names "altnames", // Units - "unit" + "unit", "ucumUnit", + // Relations + "identity", "relations" }; private static readonly Regex NamespacePattern = new( @@ -107,6 +109,7 @@ private sealed class SchemaValidationContext public JsonSourceLocator? SourceLocator { get; set; } public Dictionary ExternalSchemas { get; } = new(); public bool ValidationExtensionEnabled { get; set; } + public HashSet EnabledExtensions { get; } = new(StringComparer.Ordinal); } /// @@ -147,6 +150,16 @@ private sealed class SchemaValidationContext "has", "default" }; + private static readonly HashSet KnownExtensionNames = new(StringComparer.Ordinal) + { + "JSONStructureImport", + "JSONStructureAlternateNames", + "JSONStructureUnits", + "JSONStructureConditionalComposition", + "JSONStructureValidation", + "JSONStructureRelations" + }; + static SchemaValidator() { AllTypes = new HashSet(PrimitiveTypes, StringComparer.Ordinal); @@ -322,8 +335,9 @@ private ValidationResult ValidateCore(JsonNode? schema) // Store root schema for ref resolution ctx.RootSchema = schema; - // Detect if validation extensions are enabled - ctx.ValidationExtensionEnabled = IsValidationExtensionEnabled(schema); + // Detect enabled extensions + ctx.EnabledExtensions.UnionWith(GetEnabledExtensions(schema)); + ctx.ValidationExtensionEnabled = ctx.EnabledExtensions.Contains("JSONStructureValidation"); // Process imports if enabled if (_options.AllowImport && schema is JsonObject schemaObj) @@ -345,33 +359,34 @@ private ValidationResult ValidateCore(JsonNode? schema) /// Checks if the validation extension is enabled via $schema or $uses. /// private static bool IsValidationExtensionEnabled(JsonNode? schema) + => GetEnabledExtensions(schema).Contains("JSONStructureValidation"); + + private static HashSet GetEnabledExtensions(JsonNode? schema) { + var enabledExtensions = new HashSet(StringComparer.Ordinal); + if (schema is not JsonObject schemaObj) - return false; + return enabledExtensions; - // Check $schema - validation meta-schema enables all validation keywords if (schemaObj.TryGetPropertyValue("$schema", out var schemaValue) && - schemaValue is JsonValue sv && sv.TryGetValue(out var schemaUri)) + schemaValue is JsonValue sv && sv.TryGetValue(out var schemaUri) && + schemaUri.Contains("/meta/validation/")) { - // Check for validation meta-schema - if (schemaUri.Contains("/meta/validation/")) - return true; + enabledExtensions.Add("JSONStructureValidation"); } - // Check $uses for JSONStructureValidation if (schemaObj.TryGetPropertyValue("$uses", out var usesValue) && usesValue is JsonArray usesArray) { foreach (var useItem in usesArray) { - if (useItem is JsonValue uv && uv.TryGetValue(out var useStr)) + if (useItem is JsonValue uv && uv.TryGetValue(out var useStr) && KnownExtensionNames.Contains(useStr)) { - if (useStr == "JSONStructureValidation") - return true; + enabledExtensions.Add(useStr); } } } - return false; + return enabledExtensions; } /// @@ -396,6 +411,21 @@ private void AddExtensionKeywordWarning(ValidationResult result, string keyword, GetLocation(AppendPath(path, keyword))); } + private static bool IsExtensionEnabled(string extensionName) + => CurrentContext.EnabledExtensions.Contains(extensionName); + + private void RequireExtension(JsonObject schema, string keyword, string extensionName, string path, ValidationResult result) + { + if (!schema.ContainsKey(keyword) || IsExtensionEnabled(extensionName)) + return; + + AddError( + result, + ErrorCodes.SchemaExtensionKeywordNotEnabled, + $"'{keyword}' requires the '{extensionName}' extension to be declared in '$uses'", + AppendPath(path, keyword)); + } + /// /// Gets the source location for a JSON path. /// @@ -711,6 +741,9 @@ private void ValidateSchemaCore(JsonNode node, ValidationResult result, string p { ValidateAltnames(altnamesValue, path, result); } + + ValidateUnitsAndRelationsKeywords(schema, typeStr, path, result); + ValidateRelationsKeywords(schema, typeStr, path, result); } private void ValidateStringProperty(JsonNode? value, string keyword, string path, ValidationResult result) @@ -1099,10 +1132,167 @@ private void ValidateTypeRefResolution(JsonNode? refValue, string typePath, Vali } private bool IsNumericType(string? type) => type is - "number" or "int8" or "int16" or "int32" or "int64" or "int128" or + "number" or "integer" or "int8" or "int16" or "int32" or "int64" or "int128" or "uint8" or "uint16" or "uint32" or "uint64" or "uint128" or "float8" or "float" or "double" or "decimal"; + private void ValidateUnitsAndRelationsKeywords(JsonObject schema, string? typeStr, string path, ValidationResult result) + { + if (!schema.TryGetPropertyValue("ucumUnit", out var ucumUnitValue)) + return; + + RequireExtension(schema, "ucumUnit", "JSONStructureUnits", path, result); + + if (ucumUnitValue is not JsonValue unitValue || !unitValue.TryGetValue(out _)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "ucumUnit must be a string", AppendPath(path, "ucumUnit")); + } + + if (typeStr is not null && !IsNumericType(typeStr)) + { + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'ucumUnit' constraint is only valid for numeric types, not '{typeStr}'", AppendPath(path, "ucumUnit")); + } + } + + private void ValidateRelationsKeywords(JsonObject schema, string? typeStr, string path, ValidationResult result) + { + if (schema.TryGetPropertyValue("identity", out var identityValue)) + { + RequireExtension(schema, "identity", "JSONStructureRelations", path, result); + + if (typeStr is not "object" and not "tuple") + { + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'identity' constraint is only valid for object or tuple types, not '{typeStr}'", AppendPath(path, "identity")); + } + + if (identityValue is not JsonArray identityArray) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "identity must be an array of strings", AppendPath(path, "identity")); + } + else + { + var declaredProperties = GetDeclaredPropertyNames(schema); + for (int i = 0; i < identityArray.Count; i++) + { + if (identityArray[i] is not JsonValue itemValue || !itemValue.TryGetValue(out var propertyName)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "identity array items must be strings", AppendPath(path, $"identity/{i}")); + continue; + } + + if (!declaredProperties.Contains(propertyName)) + { + AddError(result, ErrorCodes.SchemaRequiredPropertyNotDefined, $"Identity property '{propertyName}' is not defined in properties", AppendPath(path, $"identity/{i}")); + } + } + } + } + + if (!schema.TryGetPropertyValue("relations", out var relationsValue)) + return; + + RequireExtension(schema, "relations", "JSONStructureRelations", path, result); + + if (typeStr is not "object" and not "tuple") + { + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'relations' constraint is only valid for object or tuple types, not '{typeStr}'", AppendPath(path, "relations")); + } + + if (relationsValue is not JsonObject relationsObject) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "relations must be an object", AppendPath(path, "relations")); + return; + } + + foreach (var relation in relationsObject) + { + var relationPath = AppendPath(path, $"relations/{relation.Key}"); + if (relation.Value is not JsonObject relationObject) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "relation declarations must be objects", relationPath); + continue; + } + + ValidateRelationRefObject(relationObject, "targettype", relationPath, result); + + if (!relationObject.TryGetPropertyValue("cardinality", out var cardinalityValue)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "relation declarations must include cardinality", AppendPath(relationPath, "cardinality")); + } + else if (cardinalityValue is not JsonValue cardinalityJson || !cardinalityJson.TryGetValue(out var cardinality) || (cardinality != "single" && cardinality != "multiple")) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "cardinality must be 'single' or 'multiple'", AppendPath(relationPath, "cardinality")); + } + + if (relationObject.TryGetPropertyValue("scope", out var scopeValue)) + { + if (scopeValue is JsonValue scopeJson && scopeJson.TryGetValue(out _)) + { + } + else if (scopeValue is JsonArray scopeArray) + { + for (int i = 0; i < scopeArray.Count; i++) + { + if (scopeArray[i] is not JsonValue scopeItem || !scopeItem.TryGetValue(out _)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "scope array items must be strings", AppendPath(relationPath, $"scope/{i}")); + } + } + } + else + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "scope must be a string or array of strings", AppendPath(relationPath, "scope")); + } + } + + if (relationObject.TryGetPropertyValue("qualifiertype", out _)) + { + ValidateRelationRefObject(relationObject, "qualifiertype", relationPath, result); + } + } + } + + private static HashSet GetDeclaredPropertyNames(JsonObject schema) + { + if (schema.TryGetPropertyValue("properties", out var propsValue) && propsValue is JsonObject props) + { + return new HashSet(props.Select(prop => prop.Key), StringComparer.Ordinal); + } + + return new HashSet(StringComparer.Ordinal); + } + + private void ValidateRelationRefObject(JsonObject relationObject, string keyword, string relationPath, ValidationResult result) + { + if (!relationObject.TryGetPropertyValue(keyword, out var refNode)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, $"relation declarations must include {keyword}", AppendPath(relationPath, keyword)); + return; + } + + if (refNode is not JsonObject refObject || !refObject.TryGetPropertyValue("$ref", out var refValue) || refObject.Count != 1) + { + AddError(result, ErrorCodes.SchemaTypeObjectMissingRef, $"{keyword} must be an object containing only '$ref'", AppendPath(relationPath, keyword)); + return; + } + + ValidateReference(refValue, "$ref", AppendPath(relationPath, keyword), result); + + if (refValue is JsonValue refJson && refJson.TryGetValue(out var refStr) && refStr.StartsWith("#/") && !LocalRefExists(refStr)) + { + AddError(result, ErrorCodes.SchemaRefNotFound, $"$ref target does not exist: {refStr}", AppendPath(AppendPath(relationPath, keyword), "$ref")); + } + } + + private static bool LocalRefExists(string refStr) + { + var ctx = CurrentContext; + if (ctx.DefinedRefs.Contains(refStr)) + return true; + + return ctx.ImportNamespaces.Any(ns => refStr.StartsWith(ns + "/", StringComparison.Ordinal)); + } + private void ValidateCrossTypeConstraints(JsonObject schema, string? typeStr, string path, ValidationResult result) { var isString = typeStr == "string"; diff --git a/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs b/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs index 12a812d..aeb8a4b 100644 --- a/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs +++ b/dotnet/tests/JsonStructure.Tests/Validation/RelationsValidationTests.cs @@ -152,7 +152,7 @@ public void Validate_RelationWithQualifierType_ReturnsSuccess() result.Errors.ShouldBeEmpty(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_IdentityOnNonObjectType_ReturnsError() { var result = _validator.Validate(new JsonObject @@ -168,7 +168,7 @@ public void Validate_IdentityOnNonObjectType_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_IdentityThatIsNotArray_ReturnsError() { var schema = CreateRelationsSchema(); @@ -179,7 +179,7 @@ public void Validate_IdentityThatIsNotArray_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_IdentityWithUnknownProperty_ReturnsError() { var schema = CreateRelationsSchema(); @@ -190,7 +190,7 @@ public void Validate_IdentityWithUnknownProperty_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_RelationsOnNonObjectType_ReturnsError() { var result = _validator.Validate(new JsonObject @@ -206,7 +206,7 @@ public void Validate_RelationsOnNonObjectType_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_InvalidRelationCardinality_ReturnsError() { var schema = CreateRelationsSchema(); @@ -224,7 +224,7 @@ public void Validate_InvalidRelationCardinality_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_RelationMissingTargettype_ReturnsError() { var schema = CreateRelationsSchema(); @@ -241,7 +241,7 @@ public void Validate_RelationMissingTargettype_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending Relations keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_RelationMissingCardinality_ReturnsError() { var schema = CreateRelationsSchema(); diff --git a/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs b/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs index 43067d9..8287d21 100644 --- a/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs +++ b/dotnet/tests/JsonStructure.Tests/Validation/UcumUnitValidationTests.cs @@ -59,7 +59,7 @@ public void Validate_ExtendedNumericTypeWithUcumUnit_ReturnsSuccess(string type) result.Errors.ShouldBeEmpty(); } - [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_NonNumericTypeWithUcumUnit_ReturnsError() { var result = _validator.Validate(CreateUcumUnitSchema("string", JsonValue.Create("m")!)); @@ -67,7 +67,7 @@ public void Validate_NonNumericTypeWithUcumUnit_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_NumericUcumUnitValue_ReturnsError() { var result = _validator.Validate(CreateUcumUnitSchema("number", JsonValue.Create(42)!)); @@ -75,7 +75,7 @@ public void Validate_NumericUcumUnitValue_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_ArrayUcumUnitValue_ReturnsError() { var result = _validator.Validate(CreateUcumUnitSchema("number", new JsonArray("m"))); @@ -83,7 +83,7 @@ public void Validate_ArrayUcumUnitValue_ReturnsError() result.IsValid.ShouldBeFalse(); } - [Fact(Skip = "Pending ucumUnit keyword enforcement in the .NET schema validator")] + [Fact] public void Validate_ObjectUcumUnitValue_ReturnsError() { var result = _validator.Validate(CreateUcumUnitSchema("number", new JsonObject { ["code"] = "m" })); From d49fd7820ea13a6c4c5f4f26c9eeed59fcc4882f Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:14:02 +0200 Subject: [PATCH 05/20] Go: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/relations_ucum_unit_test.go | 91 ++++++++++++++++--- go/schema_validator.go | 156 ++++++++++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 15 deletions(-) diff --git a/go/relations_ucum_unit_test.go b/go/relations_ucum_unit_test.go index 30f72ea..4bf799e 100644 --- a/go/relations_ucum_unit_test.go +++ b/go/relations_ucum_unit_test.go @@ -84,19 +84,31 @@ func TestUcumUnitValidationScenarios(t *testing.T) { } t.Run("invalid non-numeric type with ucumUnit", func(t *testing.T) { - t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + result := validator.Validate(createUcumUnitSchema("string", "m", nil)) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid numeric ucumUnit value", func(t *testing.T) { - t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + result := validator.Validate(createUcumUnitSchema("number", 42, nil)) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid array ucumUnit value", func(t *testing.T) { - t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + result := validator.Validate(createUcumUnitSchema("number", []interface{}{"m"}, nil)) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid object ucumUnit value", func(t *testing.T) { - t.Skip("Pending ucumUnit keyword enforcement in the Go schema validator") + result := validator.Validate(createUcumUnitSchema("number", map[string]interface{}{"code": "m"}, nil)) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) } @@ -176,30 +188,87 @@ func TestRelationsValidationScenarios(t *testing.T) { }) t.Run("invalid identity on non-object type", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["type"] = "string" + schema["identity"] = []interface{}{"id"} + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid identity that is not an array", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["identity"] = "id" + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid identity with missing properties", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["identity"] = []interface{}{"missing"} + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid relations on non-object type", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["type"] = "string" + schema["relations"] = map[string]interface{}{} + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid relation cardinality", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "customer": map[string]interface{}{ + "cardinality": "many", + "targettype": map[string]interface{}{"$ref": "#/definitions/Customer"}, + }, + } + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid relation missing targettype", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "customer": map[string]interface{}{ + "cardinality": "single", + }, + } + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) t.Run("invalid relation missing cardinality", func(t *testing.T) { - t.Skip("Pending Relations keyword enforcement in the Go schema validator") + schema := createRelationsSchema() + schema["relations"] = map[string]interface{}{ + "customer": map[string]interface{}{ + "targettype": map[string]interface{}{"$ref": "#/definitions/Customer"}, + }, + } + + result := validator.Validate(schema) + if result.IsValid { + t.Fatalf("expected invalid schema") + } }) } diff --git a/go/schema_validator.go b/go/schema_validator.go index aafbdbb..728f419 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -113,7 +113,7 @@ func (v *SchemaValidator) ValidateJSON(jsonData []byte) (ValidationResult, error sourceLocator: NewJsonSourceLocator(string(jsonData)), externalSchemas: v.externalSchemas, } - + schemaMap, ok := schema.(map[string]interface{}) if !ok { ctx.addError("#", "Schema must be an object", SchemaInvalidType) @@ -143,6 +143,152 @@ var validationExtensionKeywords = map[string]bool{ "has": true, "default": true, } +func hasExtension(schema map[string]interface{}, extension string) bool { + if uses, ok := schema["$uses"].([]interface{}); ok { + for _, use := range uses { + if useStr, ok := use.(string); ok && useStr == extension { + return true + } + } + } + return false +} + +func (ctx *schemaValidationContext) validateUcumUnitKeyword(schema map[string]interface{}, path string) { + ucumUnit, ok := schema["ucumUnit"] + if !ok { + return + } + + if !hasExtension(schema, "JSONStructureUnits") { + ctx.addError(path+"/ucumUnit", "ucumUnit requires JSONStructureUnits in $uses", SchemaExtensionKeywordNotEnabled) + } + + if _, ok := ucumUnit.(string); !ok { + ctx.addError(path+"/ucumUnit", "ucumUnit must be a string", SchemaKeywordInvalidType) + } + + typeStr, ok := schema["type"].(string) + if !ok || !isNumericType(typeStr) { + ctx.addError(path+"/ucumUnit", "ucumUnit is only valid for numeric types", SchemaConstraintInvalidForType) + } +} + +func (ctx *schemaValidationContext) validateRelationsKeywords(schema map[string]interface{}, path string) { + _, hasIdentity := schema["identity"] + relations, hasRelations := schema["relations"] + if !hasIdentity && !hasRelations { + return + } + + if !hasExtension(schema, "JSONStructureRelations") { + if hasIdentity { + ctx.addError(path+"/identity", "identity requires JSONStructureRelations in $uses", SchemaExtensionKeywordNotEnabled) + } + if hasRelations { + ctx.addError(path+"/relations", "relations requires JSONStructureRelations in $uses", SchemaExtensionKeywordNotEnabled) + } + } + + typeStr, hasType := schema["type"].(string) + supportsRelations := hasType && (typeStr == "object" || typeStr == "tuple") + + if identity, ok := schema["identity"]; ok { + if !supportsRelations { + ctx.addError(path+"/identity", "identity is only valid for object or tuple types", SchemaConstraintInvalidForType) + } + + identityArr, ok := identity.([]interface{}) + if !ok { + ctx.addError(path+"/identity", "identity must be an array of strings", SchemaKeywordInvalidType) + } else { + properties, _ := schema["properties"].(map[string]interface{}) + for i, item := range identityArr { + identityPath := fmt.Sprintf("%s/identity[%d]", path, i) + propertyName, ok := item.(string) + if !ok { + ctx.addError(identityPath, "identity items must be strings", SchemaKeywordInvalidType) + continue + } + if properties == nil { + ctx.addError(identityPath, fmt.Sprintf("Identity property '%s' not found in properties", propertyName), SchemaRequiredPropertyNotDefined) + continue + } + if _, exists := properties[propertyName]; !exists { + ctx.addError(identityPath, fmt.Sprintf("Identity property '%s' not found in properties", propertyName), SchemaRequiredPropertyNotDefined) + } + } + } + } + + if !hasRelations { + return + } + + if !supportsRelations { + ctx.addError(path+"/relations", "relations is only valid for object or tuple types", SchemaConstraintInvalidForType) + } + + relationsMap, ok := relations.(map[string]interface{}) + if !ok { + ctx.addError(path+"/relations", "relations must be an object", SchemaKeywordInvalidType) + return + } + + for relationName, relationValue := range relationsMap { + relationPath := path + "/relations/" + relationName + relationObj, ok := relationValue.(map[string]interface{}) + if !ok { + ctx.addError(relationPath, "relation declarations must be objects", SchemaKeywordInvalidType) + continue + } + + targetType, ok := relationObj["targettype"] + if !ok { + ctx.addError(relationPath+"/targettype", "targettype is required", SchemaKeywordInvalidType) + } else { + targetTypeObj, ok := targetType.(map[string]interface{}) + refValue, hasRef := targetTypeObj["$ref"] + if !ok || !hasRef { + ctx.addError(relationPath+"/targettype", "targettype must be an object with a $ref string", SchemaKeywordInvalidType) + } else { + ctx.validateRef(refValue, relationPath+"/targettype/$ref") + } + } + + cardinalityValue, ok := relationObj["cardinality"] + if !ok { + ctx.addError(relationPath+"/cardinality", "cardinality is required", SchemaKeywordInvalidType) + } else if cardinality, ok := cardinalityValue.(string); !ok || (cardinality != "single" && cardinality != "multiple") { + ctx.addError(relationPath+"/cardinality", "cardinality must be 'single' or 'multiple'", SchemaKeywordInvalidType) + } + + if scopeValue, ok := relationObj["scope"]; ok { + switch scope := scopeValue.(type) { + case string: + case []interface{}: + for i, item := range scope { + if _, ok := item.(string); !ok { + ctx.addError(fmt.Sprintf("%s/scope[%d]", relationPath, i), "scope array items must be strings", SchemaKeywordInvalidType) + } + } + default: + ctx.addError(relationPath+"/scope", "scope must be a string or array of strings", SchemaKeywordInvalidType) + } + } + + if qualifierType, ok := relationObj["qualifiertype"]; ok { + qualifierTypeObj, ok := qualifierType.(map[string]interface{}) + refValue, hasRef := qualifierTypeObj["$ref"] + if !ok || !hasRef { + ctx.addError(relationPath+"/qualifiertype", "qualifiertype must be an object with a $ref string", SchemaKeywordInvalidType) + } else { + ctx.validateRef(refValue, relationPath+"/qualifiertype/$ref") + } + } + } +} + func (ctx *schemaValidationContext) validateSchemaDocument(schema map[string]interface{}, path string, options SchemaValidatorOptions) { // Root-level validation (path is "#" for root) isRoot := path == "#" @@ -338,6 +484,8 @@ func (ctx *schemaValidationContext) validateTypeDefinition(schema map[string]int } typeVal, hasType := schema["type"] + ctx.validateUcumUnitKeyword(schema, path) + ctx.validateRelationsKeywords(schema, path) // Type is required unless it's a conditional-only schema if !hasType { @@ -916,13 +1064,13 @@ func (ctx *schemaValidationContext) addError(path, message string, codes ...stri if len(codes) > 0 { code = codes[0] } - + // Get source location if locator is available var location JsonLocation if ctx.sourceLocator != nil { location = ctx.sourceLocator.GetLocation(path) } - + ctx.errors = append(ctx.errors, ValidationError{ Code: code, Path: path, @@ -1128,7 +1276,7 @@ func (ctx *schemaValidationContext) processImports(obj map[string]interface{}, p // rewriteRefs rewrites $ref pointers in imported content to point to their new location. func rewriteRefs(obj map[string]interface{}, targetPath string) { for key, value := range obj { - if (key == "$ref" || key == "$extends") { + if key == "$ref" || key == "$extends" { if refStr, ok := value.(string); ok && strings.HasPrefix(refStr, "#") { // Rewrite the reference refParts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(refStr, "#"), "/"), "/") From 72fb7c3254f549e3ca078e603d0d23aa930508d3 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:14:02 +0200 Subject: [PATCH 06/20] Rust: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/schema_validator.rs | 698 ++++++++++++++++++++++++--- rust/src/types.rs | 178 +++++-- rust/tests/schema_validator_tests.rs | 123 +++-- 3 files changed, 852 insertions(+), 147 deletions(-) diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index 97e61dd..2b9efed 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -90,7 +90,16 @@ impl SchemaValidator { match serde_json::from_str::(schema_json) { Ok(schema) => { - self.validate_schema_internal(&schema, &schema, &locator, &mut result, "", true, &mut HashSet::new(), 0); + self.validate_schema_internal( + &schema, + &schema, + &locator, + &mut result, + "", + true, + &mut HashSet::new(), + 0, + ); } Err(e) => { result.add_error(ValidationError::schema_error( @@ -112,7 +121,16 @@ impl SchemaValidator { pub fn validate_value(&self, schema: &Value, schema_json: &str) -> ValidationResult { let mut result = ValidationResult::new(); let locator = JsonSourceLocator::new(schema_json); - self.validate_schema_internal(schema, schema, &locator, &mut result, "", true, &mut HashSet::new(), 0); + self.validate_schema_internal( + schema, + schema, + &locator, + &mut result, + "", + true, + &mut HashSet::new(), + 0, + ); result } @@ -129,7 +147,16 @@ impl SchemaValidator { depth: usize, ) { // Pass through to internal method with proper root_schema - self.validate_schema_internal(schema, root_schema, locator, result, path, is_root, visited_refs, depth); + self.validate_schema_internal( + schema, + root_schema, + locator, + result, + path, + is_root, + visited_refs, + depth, + ); } /// Internal schema validation with root schema reference. @@ -198,17 +225,44 @@ impl SchemaValidator { // Validate $ref if present if let Some(ref_val) = obj.get("$ref") { - self.validate_ref(ref_val, schema, root_schema, locator, result, path, visited_refs, depth); + self.validate_ref( + ref_val, + schema, + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // Validate type if present if let Some(type_val) = obj.get("type") { - self.validate_type(type_val, obj, root_schema, locator, result, path, &enabled_extensions, visited_refs, depth); + self.validate_type( + type_val, + obj, + root_schema, + locator, + result, + path, + &enabled_extensions, + visited_refs, + depth, + ); } // Validate $extends if present if let Some(extends_val) = obj.get("$extends") { - self.validate_extends(extends_val, root_schema, locator, result, path, visited_refs, depth); + self.validate_extends( + extends_val, + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // Validate altnames if present @@ -216,9 +270,31 @@ impl SchemaValidator { self.validate_altnames(altnames_val, locator, result, path); } + let type_name = obj.get("type").and_then(Value::as_str); + self.validate_ucum_unit_keyword(obj, locator, result, path, type_name, &enabled_extensions); + self.validate_relations_keywords( + obj, + root_schema, + locator, + result, + path, + type_name, + &enabled_extensions, + visited_refs, + depth, + ); + // Validate definitions if let Some(defs) = obj.get("definitions") { - self.validate_definitions(defs, root_schema, locator, result, path, visited_refs, depth); + self.validate_definitions( + defs, + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // Validate enum @@ -227,7 +303,16 @@ impl SchemaValidator { } // Validate composition keywords - self.validate_composition(obj, root_schema, locator, result, path, &enabled_extensions, visited_refs, depth); + self.validate_composition( + obj, + root_schema, + locator, + result, + path, + &enabled_extensions, + visited_refs, + depth, + ); // Validate extension keywords without $uses if self.options.warn_on_unused_extension_keywords { @@ -276,16 +361,16 @@ impl SchemaValidator { let has_type = obj.contains_key("type"); let has_root = obj.contains_key("$root"); let has_definitions = obj.contains_key("definitions"); - let has_composition = obj.keys().any(|k| - ["allOf", "anyOf", "oneOf", "not", "if"].contains(&k.as_str()) - ); - + let has_composition = obj + .keys() + .any(|k| ["allOf", "anyOf", "oneOf", "not", "if"].contains(&k.as_str())); + if !has_type && !has_root && !has_composition { // Check if it has only meta keywords + definitions let has_only_meta = obj.keys().all(|k| { k.starts_with('$') || k == "definitions" || k == "name" || k == "description" }); - + if !has_only_meta || !has_definitions { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRootMissingType, @@ -424,20 +509,21 @@ impl SchemaValidator { false } }; - + if is_bare_ref || is_type_ref_only { // Check if it references itself let inner_ref = if is_bare_ref { def_obj.get("$ref").and_then(|v| v.as_str()) } else if is_type_ref_only { - def_obj.get("type") + def_obj + .get("type") .and_then(|t| t.as_object()) .and_then(|o| o.get("$ref")) .and_then(|v| v.as_str()) } else { None }; - + if inner_ref == Some(ref_str) { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRefCircular, @@ -475,16 +561,16 @@ impl SchemaValidator { if !ref_str.starts_with("#/") { return None; } - + let path_parts: Vec<&str> = ref_str[2..].split('/').collect(); let mut current = root_schema; - + for part in path_parts { // Handle JSON Pointer escaping let unescaped = part.replace("~1", "/").replace("~0", "~"); current = current.get(&unescaped)?; } - + Some(current) } @@ -505,7 +591,16 @@ impl SchemaValidator { match type_val { Value::String(type_name) => { - self.validate_single_type(type_name, obj, root_schema, locator, result, path, enabled_extensions, depth); + self.validate_single_type( + type_name, + obj, + root_schema, + locator, + result, + path, + enabled_extensions, + depth, + ); } Value::Array(types) => { // Union type: ["string", "null"] or [{"$ref": "..."}, "null"] @@ -535,7 +630,14 @@ impl SchemaValidator { if let Some(ref_val) = ref_obj.get("$ref") { if let Value::String(ref_str) = ref_val { // Validate the ref exists - self.validate_type_ref(ref_str, root_schema, locator, result, &elem_path, visited_refs); + self.validate_type_ref( + ref_str, + root_schema, + locator, + result, + &elem_path, + visited_refs, + ); } else { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRefNotString, @@ -569,7 +671,14 @@ impl SchemaValidator { if let Some(ref_val) = ref_obj.get("$ref") { if let Value::String(ref_str) = ref_val { // Validate the ref exists - self.validate_type_ref(ref_str, root_schema, locator, result, path, visited_refs); + self.validate_type_ref( + ref_str, + root_schema, + locator, + result, + path, + visited_refs, + ); } else { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRefNotString, @@ -609,7 +718,7 @@ impl SchemaValidator { visited_refs: &mut HashSet, ) { let ref_path = format!("{}/type/$ref", path); - + if ref_str.starts_with("#/definitions/") { // Check for circular reference if visited_refs.contains(ref_str) { @@ -624,7 +733,7 @@ impl SchemaValidator { false } }; - + if is_type_ref_only { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRefCircular, @@ -640,7 +749,7 @@ impl SchemaValidator { // Track this ref visited_refs.insert(ref_str.to_string()); - + // Resolve and validate if let Some(resolved) = self.resolve_ref(ref_str, root_schema) { // Check if the resolved schema itself has a type with $ref (for circular detection) @@ -648,7 +757,14 @@ impl SchemaValidator { if let Some(type_val) = def_obj.get("type") { if let Value::Object(type_obj) = type_val { if let Some(Value::String(inner_ref)) = type_obj.get("$ref") { - self.validate_type_ref(inner_ref, root_schema, locator, result, path, visited_refs); + self.validate_type_ref( + inner_ref, + root_schema, + locator, + result, + path, + visited_refs, + ); } } } @@ -661,7 +777,7 @@ impl SchemaValidator { locator.get_location(&ref_path), )); } - + visited_refs.remove(ref_str); } } @@ -679,7 +795,7 @@ impl SchemaValidator { depth: usize, ) { let type_path = format!("{}/type", path); - + // Validate type name if !is_valid_type(type_name) { result.add_error(ValidationError::schema_error( @@ -693,8 +809,18 @@ impl SchemaValidator { // Type-specific validation match type_name { - "object" => self.validate_object_type(obj, root_schema, locator, result, path, enabled_extensions, depth), - "array" | "set" => self.validate_array_type(obj, root_schema, locator, result, path, type_name), + "object" => self.validate_object_type( + obj, + root_schema, + locator, + result, + path, + enabled_extensions, + depth, + ), + "array" | "set" => { + self.validate_array_type(obj, root_schema, locator, result, path, type_name) + } "map" => self.validate_map_type(obj, root_schema, locator, result, path), "tuple" => self.validate_tuple_type(obj, root_schema, locator, result, path), "choice" => self.validate_choice_type(obj, root_schema, locator, result, path), @@ -724,7 +850,10 @@ impl SchemaValidator { if obj.contains_key("minimum") && !is_numeric { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, - format!("minimum constraint cannot be used with type '{}'", type_name), + format!( + "minimum constraint cannot be used with type '{}'", + type_name + ), &format!("{}/minimum", path), locator.get_location(&format!("{}/minimum", path)), )); @@ -732,7 +861,10 @@ impl SchemaValidator { if obj.contains_key("maximum") && !is_numeric { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, - format!("maximum constraint cannot be used with type '{}'", type_name), + format!( + "maximum constraint cannot be used with type '{}'", + type_name + ), &format!("{}/maximum", path), locator.get_location(&format!("{}/maximum", path)), )); @@ -742,7 +874,10 @@ impl SchemaValidator { if obj.contains_key("minLength") && !is_string { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, - format!("minLength constraint cannot be used with type '{}'", type_name), + format!( + "minLength constraint cannot be used with type '{}'", + type_name + ), &format!("{}/minLength", path), locator.get_location(&format!("{}/minLength", path)), )); @@ -750,7 +885,10 @@ impl SchemaValidator { if obj.contains_key("maxLength") && !is_string { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, - format!("maxLength constraint cannot be used with type '{}'", type_name), + format!( + "maxLength constraint cannot be used with type '{}'", + type_name + ), &format!("{}/maxLength", path), locator.get_location(&format!("{}/maxLength", path)), )); @@ -771,7 +909,10 @@ impl SchemaValidator { if !is_numeric { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, - format!("multipleOf constraint cannot be used with type '{}'", type_name), + format!( + "multipleOf constraint cannot be used with type '{}'", + type_name + ), &format!("{}/multipleOf", path), locator.get_location(&format!("{}/multipleOf", path)), )); @@ -810,7 +951,7 @@ impl SchemaValidator { ) { let minimum = obj.get("minimum").and_then(Value::as_f64); let maximum = obj.get("maximum").and_then(Value::as_f64); - + if let (Some(min), Some(max)) = (minimum, maximum) { if min > max { result.add_error(ValidationError::schema_error( @@ -1024,7 +1165,16 @@ impl SchemaValidator { )); } else if let Some(items) = obj.get("items") { let items_path = format!("{}/items", path); - self.validate_schema(items, root_schema, locator, result, &items_path, false, &mut HashSet::new(), 0); + self.validate_schema( + items, + root_schema, + locator, + result, + &items_path, + false, + &mut HashSet::new(), + 0, + ); } // Validate minItems/maxItems constraints @@ -1073,7 +1223,16 @@ impl SchemaValidator { )); } else if let Some(values) = obj.get("values") { let values_path = format!("{}/values", path); - self.validate_schema(values, root_schema, locator, result, &values_path, false, &mut HashSet::new(), 0); + self.validate_schema( + values, + root_schema, + locator, + result, + &values_path, + false, + &mut HashSet::new(), + 0, + ); } } @@ -1106,7 +1265,7 @@ impl SchemaValidator { match tuple { Value::Array(arr) => { let properties = obj.get("properties").and_then(Value::as_object); - + for (i, item) in arr.iter().enumerate() { match item { Value::String(s) => { @@ -1115,7 +1274,10 @@ impl SchemaValidator { if !props.contains_key(s) { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaTuplePropertyNotDefined, - format!("Tuple element references undefined property: {}", s), + format!( + "Tuple element references undefined property: {}", + s + ), &format!("{}/{}", tuple_path, i), locator.get_location(&format!("{}/{}", tuple_path, i)), )); @@ -1226,7 +1388,15 @@ impl SchemaValidator { Value::Object(defs_obj) => { for (def_name, def_schema) in defs_obj { let def_path = format!("{}/{}", defs_path, def_name); - self.validate_definition_or_namespace(def_schema, root_schema, locator, result, &def_path, visited_refs, depth); + self.validate_definition_or_namespace( + def_schema, + root_schema, + locator, + result, + &def_path, + visited_refs, + depth, + ); } } _ => { @@ -1266,10 +1436,10 @@ impl SchemaValidator { let has_type = def_obj.contains_key("type"); let has_ref = def_obj.contains_key("$ref"); let has_definitions = def_obj.contains_key("definitions"); - let has_composition = def_obj.keys().any(|k| - ["allOf", "anyOf", "oneOf", "not", "if"].contains(&k.as_str()) - ); - + let has_composition = def_obj + .keys() + .any(|k| ["allOf", "anyOf", "oneOf", "not", "if"].contains(&k.as_str())); + if has_type || has_ref || has_definitions || has_composition { // This is a type definition - validate it self.validate_schema_internal( @@ -1287,21 +1457,31 @@ impl SchemaValidator { // (objects with type, $ref, etc.) let is_namespace = def_obj.values().all(|v| { if let Value::Object(child) = v { - child.contains_key("type") - || child.contains_key("$ref") + child.contains_key("type") + || child.contains_key("$ref") || child.contains_key("definitions") - || child.keys().any(|k| ["allOf", "anyOf", "oneOf"].contains(&k.as_str())) - || child.values().all(|cv| cv.is_object()) // nested namespace + || child + .keys() + .any(|k| ["allOf", "anyOf", "oneOf"].contains(&k.as_str())) + || child.values().all(|cv| cv.is_object()) // nested namespace } else { false } }); - + if is_namespace { // Recursively validate as namespace for (child_name, child_schema) in def_obj { let child_path = format!("{}/{}", path, child_name); - self.validate_definition_or_namespace(child_schema, root_schema, locator, result, &child_path, visited_refs, depth + 1); + self.validate_definition_or_namespace( + child_schema, + root_schema, + locator, + result, + &child_path, + visited_refs, + depth + 1, + ); } } else { // Not a namespace and no type - error @@ -1386,29 +1566,74 @@ impl SchemaValidator { ) { // allOf if let Some(all_of) = obj.get("allOf") { - self.validate_composition_array(all_of, "allOf", root_schema, locator, result, path, visited_refs, depth); + self.validate_composition_array( + all_of, + "allOf", + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // anyOf if let Some(any_of) = obj.get("anyOf") { - self.validate_composition_array(any_of, "anyOf", root_schema, locator, result, path, visited_refs, depth); + self.validate_composition_array( + any_of, + "anyOf", + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // oneOf if let Some(one_of) = obj.get("oneOf") { - self.validate_composition_array(one_of, "oneOf", root_schema, locator, result, path, visited_refs, depth); + self.validate_composition_array( + one_of, + "oneOf", + root_schema, + locator, + result, + path, + visited_refs, + depth, + ); } // not if let Some(not) = obj.get("not") { let not_path = format!("{}/not", path); - self.validate_schema_internal(not, root_schema, locator, result, ¬_path, false, visited_refs, depth + 1); + self.validate_schema_internal( + not, + root_schema, + locator, + result, + ¬_path, + false, + visited_refs, + depth + 1, + ); } // if/then/else if let Some(if_schema) = obj.get("if") { let if_path = format!("{}/if", path); - self.validate_schema_internal(if_schema, root_schema, locator, result, &if_path, false, visited_refs, depth + 1); + self.validate_schema_internal( + if_schema, + root_schema, + locator, + result, + &if_path, + false, + visited_refs, + depth + 1, + ); } if let Some(then_schema) = obj.get("then") { @@ -1421,7 +1646,16 @@ impl SchemaValidator { )); } let then_path = format!("{}/then", path); - self.validate_schema_internal(then_schema, root_schema, locator, result, &then_path, false, visited_refs, depth + 1); + self.validate_schema_internal( + then_schema, + root_schema, + locator, + result, + &then_path, + false, + visited_refs, + depth + 1, + ); } if let Some(else_schema) = obj.get("else") { @@ -1434,7 +1668,16 @@ impl SchemaValidator { )); } let else_path = format!("{}/else", path); - self.validate_schema_internal(else_schema, root_schema, locator, result, &else_path, false, visited_refs, depth + 1); + self.validate_schema_internal( + else_schema, + root_schema, + locator, + result, + &else_path, + false, + visited_refs, + depth + 1, + ); } } @@ -1464,10 +1707,19 @@ impl SchemaValidator { )); return; } - + for (i, item) in arr.iter().enumerate() { let item_path = format!("{}/{}", keyword_path, i); - self.validate_schema_internal(item, root_schema, locator, result, &item_path, false, visited_refs, depth + 1); + self.validate_schema_internal( + item, + root_schema, + locator, + result, + &item_path, + false, + visited_refs, + depth + 1, + ); } } _ => { @@ -1499,7 +1751,7 @@ impl SchemaValidator { _depth: usize, ) { let extends_path = format!("{}/$extends", path); - + // $extends can be a string or an array of strings let refs: Vec<(String, String)> = match extends_val { Value::String(s) => { @@ -1574,7 +1826,9 @@ impl SchemaValidator { } // Resolve reference - if ref_str.starts_with("#/definitions/") && self.resolve_ref(&ref_str, root_schema).is_none() { + if ref_str.starts_with("#/definitions/") + && self.resolve_ref(&ref_str, root_schema).is_none() + { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaExtendsNotFound, format!("$extends reference not found: {}", ref_str), @@ -1595,7 +1849,7 @@ impl SchemaValidator { path: &str, ) { let altnames_path = format!("{}/altnames", path); - + match altnames_val { Value::Object(obj) => { for (key, value) in obj { @@ -1620,6 +1874,297 @@ impl SchemaValidator { } } + fn validate_ucum_unit_keyword( + &self, + obj: &serde_json::Map, + locator: &JsonSourceLocator, + result: &mut ValidationResult, + path: &str, + type_name: Option<&str>, + enabled_extensions: &HashSet<&str>, + ) { + let Some(ucum_unit) = obj.get("ucumUnit") else { + return; + }; + + if !enabled_extensions.contains("JSONStructureUnits") { + let ucum_path = format!("{}/ucumUnit", path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaExtensionKeywordWithoutUses, + "ucumUnit requires 'JSONStructureUnits' in $uses", + &ucum_path, + locator.get_location(&ucum_path), + )); + } + + if !ucum_unit.is_string() { + let ucum_path = format!("{}/ucumUnit", path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "ucumUnit must be a string", + &ucum_path, + locator.get_location(&ucum_path), + )); + } + + if type_name.is_none_or(|name| !crate::types::is_numeric_type(name)) { + let ucum_path = format!("{}/ucumUnit", path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + "ucumUnit is only valid for numeric types", + &ucum_path, + locator.get_location(&ucum_path), + )); + } + } + + fn validate_relations_keywords( + &self, + obj: &serde_json::Map, + root_schema: &Value, + locator: &JsonSourceLocator, + result: &mut ValidationResult, + path: &str, + type_name: Option<&str>, + enabled_extensions: &HashSet<&str>, + visited_refs: &mut HashSet, + depth: usize, + ) { + let has_identity = obj.contains_key("identity"); + let has_relations = obj.contains_key("relations"); + if !has_identity && !has_relations { + return; + } + + let relations_enabled = enabled_extensions.contains("JSONStructureRelations"); + if has_identity && !relations_enabled { + let identity_path = format!("{}/identity", path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaExtensionKeywordWithoutUses, + "identity requires 'JSONStructureRelations' in $uses", + &identity_path, + locator.get_location(&identity_path), + )); + } + if has_relations && !relations_enabled { + let relations_path = format!("{}/relations", path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaExtensionKeywordWithoutUses, + "relations requires 'JSONStructureRelations' in $uses", + &relations_path, + locator.get_location(&relations_path), + )); + } + + let supports_relations = matches!(type_name, Some("object" | "tuple")); + + if let Some(identity) = obj.get("identity") { + let identity_path = format!("{}/identity", path); + if !supports_relations { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + "identity is only valid for object or tuple types", + &identity_path, + locator.get_location(&identity_path), + )); + } + + match identity { + Value::Array(items) => { + let properties = obj.get("properties").and_then(Value::as_object); + for (index, item) in items.iter().enumerate() { + let item_path = format!("{}/{}", identity_path, index); + match item { + Value::String(name) => { + if properties.is_none_or(|props| !props.contains_key(name)) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaRequiredPropertyNotDefined, + format!( + "Identity property '{}' not found in properties", + name + ), + &item_path, + locator.get_location(&item_path), + )); + } + } + _ => result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "identity items must be strings", + &item_path, + locator.get_location(&item_path), + )), + } + } + } + _ => result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "identity must be an array of strings", + &identity_path, + locator.get_location(&identity_path), + )), + } + } + + let Some(relations) = obj.get("relations") else { + return; + }; + + let relations_path = format!("{}/relations", path); + if !supports_relations { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + "relations is only valid for object or tuple types", + &relations_path, + locator.get_location(&relations_path), + )); + } + + let Value::Object(relations_obj) = relations else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "relations must be an object", + &relations_path, + locator.get_location(&relations_path), + )); + return; + }; + + for (relation_name, relation_decl) in relations_obj { + let relation_path = format!("{}/{}", relations_path, relation_name); + let Value::Object(relation_obj) = relation_decl else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "relation declarations must be objects", + &relation_path, + locator.get_location(&relation_path), + )); + continue; + }; + + self.validate_relation_ref_object( + relation_obj, + "targettype", + relation_path.as_str(), + root_schema, + locator, + result, + visited_refs, + depth, + ); + + let cardinality_path = format!("{}/cardinality", relation_path); + match relation_obj.get("cardinality") { + Some(Value::String(value)) if value == "single" || value == "multiple" => {} + Some(_) => result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "cardinality must be 'single' or 'multiple'", + &cardinality_path, + locator.get_location(&cardinality_path), + )), + None => result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "cardinality is required", + &cardinality_path, + locator.get_location(&cardinality_path), + )), + } + + if let Some(scope) = relation_obj.get("scope") { + let scope_path = format!("{}/scope", relation_path); + match scope { + Value::String(_) => {} + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + if !item.is_string() { + let item_path = format!("{}/{}", scope_path, index); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "scope array items must be strings", + &item_path, + locator.get_location(&item_path), + )); + } + } + } + _ => result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + "scope must be a string or array of strings", + &scope_path, + locator.get_location(&scope_path), + )), + } + } + + if relation_obj.contains_key("qualifiertype") { + self.validate_relation_ref_object( + relation_obj, + "qualifiertype", + relation_path.as_str(), + root_schema, + locator, + result, + visited_refs, + depth, + ); + } + } + } + + fn validate_relation_ref_object( + &self, + relation_obj: &serde_json::Map, + keyword: &str, + relation_path: &str, + root_schema: &Value, + locator: &JsonSourceLocator, + result: &mut ValidationResult, + visited_refs: &mut HashSet, + depth: usize, + ) { + let keyword_path = format!("{}/{}", relation_path, keyword); + let Some(ref_value) = relation_obj.get(keyword) else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + format!("{} is required", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + return; + }; + + let Value::Object(ref_object) = ref_value else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + format!("{} must be an object with a $ref string", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + return; + }; + + let Some(ref_target) = ref_object.get("$ref") else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + format!("{} must be an object with a $ref string", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + return; + }; + + self.validate_ref( + ref_target, + ref_value, + root_schema, + locator, + result, + &keyword_path, + visited_refs, + depth + 1, + ); + } + /// Checks for extension keywords used without enabling the extension. fn check_extension_keywords( &self, @@ -1630,7 +2175,8 @@ impl SchemaValidator { enabled_extensions: &HashSet<&str>, ) { let validation_enabled = enabled_extensions.contains("JSONStructureValidation"); - let composition_enabled = enabled_extensions.contains("JSONStructureConditionalComposition"); + let composition_enabled = + enabled_extensions.contains("JSONStructureConditionalComposition"); for (key, _) in obj { // Check validation keywords @@ -1675,7 +2221,7 @@ mod tests { "name": "TestSchema", "type": "string" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(result.is_valid()); @@ -1687,7 +2233,7 @@ mod tests { "name": "TestSchema", "type": "string" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1699,7 +2245,7 @@ mod tests { "$id": "https://example.com/schema", "type": "string" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1712,7 +2258,7 @@ mod tests { "name": "TestSchema", "type": "invalid_type" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1725,7 +2271,7 @@ mod tests { "name": "TestSchema", "type": "array" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1738,7 +2284,7 @@ mod tests { "name": "TestSchema", "type": "map" }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1756,7 +2302,7 @@ mod tests { }, "tuple": ["first", "second"] }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(result.is_valid()); @@ -1774,7 +2320,7 @@ mod tests { "number": { "type": "int32" } } }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(result.is_valid()); @@ -1788,7 +2334,7 @@ mod tests { "type": "string", "enum": [] }"#; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid()); @@ -1809,7 +2355,7 @@ mod tests { "value": { "type": { "$ref": "#/definitions/Inner" } } } }"##; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); for err in result.all_errors() { @@ -1828,7 +2374,7 @@ mod tests { "value": { "type": { "$ref": "#/definitions/Undefined" } } } }"##; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); assert!(!result.is_valid(), "Schema with undefined ref should fail"); @@ -1844,7 +2390,7 @@ mod tests { "value": { "type": ["string", "null"] } } }"##; - + let validator = SchemaValidator::new(); let result = validator.validate(schema); for err in result.all_errors() { diff --git a/rust/src/types.rs b/rust/src/types.rs index 74b5078..8001142 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -187,7 +187,11 @@ impl std::error::Error for ValidationError {} impl fmt::Display for ValidationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.location.is_unknown() { - write!(f, "[{}] {}: {} at {}", self.severity, self.code, self.message, self.path) + write!( + f, + "[{}] {}: {} at {}", + self.severity, self.code, self.message, self.path + ) } else { write!( f, @@ -284,90 +288,170 @@ impl ValidationResult { /// Primitive types in JSON Structure. pub const PRIMITIVE_TYPES: &[&str] = &[ - "string", "boolean", "null", "number", - "int8", "int16", "int32", "int64", "int128", - "uint8", "uint16", "uint32", "uint64", "uint128", - "float", "float8", "double", "decimal", - "date", "time", "datetime", "duration", - "uuid", "uri", "binary", "jsonpointer", + "string", + "boolean", + "null", + "number", + "int8", + "int16", + "int32", + "int64", + "int128", + "uint8", + "uint16", + "uint32", + "uint64", + "uint128", + "float", + "float8", + "double", + "decimal", + "date", + "time", + "datetime", + "duration", + "uuid", + "uri", + "binary", + "jsonpointer", "integer", // alias for int32 ]; /// Compound types in JSON Structure. -pub const COMPOUND_TYPES: &[&str] = &[ - "object", "array", "set", "map", "tuple", "choice", "any", -]; +pub const COMPOUND_TYPES: &[&str] = &["object", "array", "set", "map", "tuple", "choice", "any"]; /// Numeric types in JSON Structure. pub const NUMERIC_TYPES: &[&str] = &[ - "number", "integer", - "int8", "int16", "int32", "int64", "int128", - "uint8", "uint16", "uint32", "uint64", "uint128", - "float", "float8", "double", "decimal", + "number", "integer", "int8", "int16", "int32", "int64", "int128", "uint8", "uint16", "uint32", + "uint64", "uint128", "float", "float8", "double", "decimal", ]; /// Integer types in JSON Structure. pub const INTEGER_TYPES: &[&str] = &[ - "integer", - "int8", "int16", "int32", "int64", "int128", - "uint8", "uint16", "uint32", "uint64", "uint128", + "integer", "int8", "int16", "int32", "int64", "int128", "uint8", "uint16", "uint32", "uint64", + "uint128", ]; /// Core schema keywords. pub const SCHEMA_KEYWORDS: &[&str] = &[ - "$schema", "$id", "$ref", "definitions", "$import", "$importdefs", - "$comment", "$extends", "$abstract", "$root", "$uses", "$offers", - "name", "abstract", - "type", "enum", "const", "default", - "title", "description", "examples", + "$schema", + "$id", + "$ref", + "definitions", + "$import", + "$importdefs", + "$comment", + "$extends", + "$abstract", + "$root", + "$uses", + "$offers", + "name", + "abstract", + "type", + "enum", + "const", + "default", + "title", + "description", + "examples", // Object keywords - "properties", "additionalProperties", "required", "propertyNames", - "minProperties", "maxProperties", "dependentRequired", + "properties", + "additionalProperties", + "required", + "propertyNames", + "minProperties", + "maxProperties", + "dependentRequired", // Array/Set/Tuple keywords - "items", "minItems", "maxItems", "uniqueItems", "contains", - "minContains", "maxContains", + "items", + "minItems", + "maxItems", + "uniqueItems", + "contains", + "minContains", + "maxContains", // String keywords - "minLength", "maxLength", "pattern", "format", "contentEncoding", "contentMediaType", + "minLength", + "maxLength", + "pattern", + "format", + "contentEncoding", + "contentMediaType", "contentCompression", // Number keywords - "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", - "precision", "scale", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "precision", + "scale", // Map keywords "values", // Choice keywords - "choices", "selector", + "choices", + "selector", // Tuple keywords "tuple", // Conditional composition - "allOf", "anyOf", "oneOf", "not", "if", "then", "else", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", // Alternate names "altnames", // Units "unit", + "ucumUnit", + // Relations + "identity", + "relations", ]; /// Validation extension keywords that require JSONStructureValidation. pub const VALIDATION_EXTENSION_KEYWORDS: &[&str] = &[ // Numeric validation - "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", // String validation - "minLength", "maxLength", "pattern", "format", + "minLength", + "maxLength", + "pattern", + "format", // Array/Set validation - "minItems", "maxItems", "uniqueItems", "contains", "minContains", "maxContains", + "minItems", + "maxItems", + "uniqueItems", + "contains", + "minContains", + "maxContains", // Object/Map validation - "minProperties", "maxProperties", "dependentRequired", "propertyNames", "patternProperties", + "minProperties", + "maxProperties", + "dependentRequired", + "propertyNames", + "patternProperties", // Map-specific validation - "minEntries", "maxEntries", "keyNames", + "minEntries", + "maxEntries", + "keyNames", // Content validation - "contentEncoding", "contentMediaType", "contentCompression", + "contentEncoding", + "contentMediaType", + "contentCompression", // Default value "default", ]; /// Conditional composition keywords that require JSONStructureConditionalComposition. -pub const COMPOSITION_KEYWORDS: &[&str] = &[ - "allOf", "anyOf", "oneOf", "not", "if", "then", "else", -]; +pub const COMPOSITION_KEYWORDS: &[&str] = &["allOf", "anyOf", "oneOf", "not", "if", "then", "else"]; /// Known extension names. pub const KNOWN_EXTENSIONS: &[&str] = &[ @@ -376,13 +460,23 @@ pub const KNOWN_EXTENSIONS: &[&str] = &[ "JSONStructureUnits", "JSONStructureConditionalComposition", "JSONStructureValidation", + "JSONStructureRelations", ]; /// Valid format values for the "format" keyword. #[allow(dead_code)] pub const VALID_FORMATS: &[&str] = &[ - "ipv4", "ipv6", "email", "idn-email", "hostname", "idn-hostname", - "iri", "iri-reference", "uri-template", "relative-json-pointer", "regex", + "ipv4", + "ipv6", + "email", + "idn-email", + "hostname", + "idn-hostname", + "iri", + "iri-reference", + "uri-template", + "relative-json-pointer", + "regex", ]; /// Returns true if the given type name is a valid JSON Structure type. diff --git a/rust/tests/schema_validator_tests.rs b/rust/tests/schema_validator_tests.rs index edeab98..384ce6d 100644 --- a/rust/tests/schema_validator_tests.rs +++ b/rust/tests/schema_validator_tests.rs @@ -194,24 +194,49 @@ fn test_valid_map_schema() { #[test] fn test_all_primitive_types() { let types = [ - "string", "boolean", "null", "number", "integer", - "int8", "int16", "int32", "int64", "int128", - "uint8", "uint16", "uint32", "uint64", "uint128", - "float", "float8", "double", "decimal", - "date", "time", "datetime", "duration", - "uuid", "uri", "binary", "jsonpointer", "any", + "string", + "boolean", + "null", + "number", + "integer", + "int8", + "int16", + "int32", + "int64", + "int128", + "uint8", + "uint16", + "uint32", + "uint64", + "uint128", + "float", + "float8", + "double", + "decimal", + "date", + "time", + "datetime", + "duration", + "uuid", + "uri", + "binary", + "jsonpointer", + "any", ]; let validator = SchemaValidator::new(); - + for type_name in &types { - let schema = format!(r##"{{ + let schema = format!( + r##"{{ "$schema": "https://json-structure.org/meta/core/v0/#", "$id": "https://example.com/schema/{}", "name": "{}Schema", "type": "{}" - }}"##, type_name, type_name, type_name); - + }}"##, + type_name, type_name, type_name + ); + let result = validator.validate(&schema); assert!(result.is_valid(), "Type {} should be valid", type_name); } @@ -317,7 +342,10 @@ fn test_valid_validation_keywords() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(result.is_valid(), "Validation keywords schema should be valid"); + assert!( + result.is_valid(), + "Validation keywords schema should be valid" + ); } // ============================================================================= @@ -346,7 +374,10 @@ fn test_invalid_missing_name_with_type() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Schema with type but no name should be invalid"); + assert!( + !result.is_valid(), + "Schema with type but no name should be invalid" + ); assert!(result.errors().any(|e| e.message.contains("name"))); } @@ -416,7 +447,10 @@ fn test_invalid_choice_missing_choices() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Choice without choices should be invalid"); + assert!( + !result.is_valid(), + "Choice without choices should be invalid" + ); } #[test] @@ -429,7 +463,10 @@ fn test_invalid_tuple_missing_definition() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Tuple without properties/tuple should be invalid"); + assert!( + !result.is_valid(), + "Tuple without properties/tuple should be invalid" + ); } // ============================================================================= @@ -446,7 +483,10 @@ fn test_invalid_ref_not_found() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Reference to non-existent definition should be invalid"); + assert!( + !result.is_valid(), + "Reference to non-existent definition should be invalid" + ); } #[test] @@ -464,7 +504,10 @@ fn test_invalid_circular_ref() { let validator = SchemaValidator::new(); let result = validator.validate(schema); // Circular refs should be detected - assert!(!result.is_valid() || result.errors().count() > 0 || true, "Circular reference schema"); + assert!( + !result.is_valid() || result.errors().count() > 0 || true, + "Circular reference schema" + ); } // ============================================================================= @@ -496,7 +539,10 @@ fn test_invalid_duplicate_enum() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Duplicate enum values should be invalid"); + assert!( + !result.is_valid(), + "Duplicate enum values should be invalid" + ); } // ============================================================================= @@ -538,10 +584,13 @@ fn test_tuple_uses_properties_and_tuple_not_prefixitems() { }, "tuple": ["first", "second"] }"##; - + let validator = SchemaValidator::new(); let result = validator.validate(valid_schema); - assert!(result.is_valid(), "Tuple with properties+tuple should be valid"); + assert!( + result.is_valid(), + "Tuple with properties+tuple should be valid" + ); } // ============================================================================= @@ -563,7 +612,10 @@ fn test_choice_uses_choices_and_selector() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(result.is_valid(), "Choice with selector+choices should be valid"); + assert!( + result.is_valid(), + "Choice with selector+choices should be valid" + ); } // ============================================================================= @@ -639,7 +691,10 @@ fn test_valid_required_exist_in_properties() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(result.is_valid(), "Required properties that exist should be valid"); + assert!( + result.is_valid(), + "Required properties that exist should be valid" + ); } // ============================================================================= @@ -660,7 +715,10 @@ fn test_invalid_required_not_in_properties() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(!result.is_valid(), "Required property not in properties should be invalid"); + assert!( + !result.is_valid(), + "Required property not in properties should be invalid" + ); } // ============================================================================= @@ -856,28 +914,37 @@ fn test_valid_ucum_unit_with_unit_annotation() { }"##; let validator = SchemaValidator::new(); let result = validator.validate(schema); - assert!(result.is_valid(), "unit and ucumUnit should be allowed together"); + assert!( + result.is_valid(), + "unit and ucumUnit should be allowed together" + ); } #[test] fn test_valid_ucum_unit_on_extended_numeric_types() { let validator = SchemaValidator::new(); for type_name in ["int32", "float", "double", "decimal"] { - let schema = format!(r##"{{ + let schema = format!( + r##"{{ "$schema": "https://json-structure.org/meta/extended/v0/#", "$id": "https://example.com/schema/ucum-{}", "name": "{}WithUcumUnit", "$uses": ["JSONStructureUnits"], "type": "{}", "ucumUnit": "m" - }}"##, type_name, type_name, type_name); + }}"##, + type_name, type_name, type_name + ); let result = validator.validate(&schema); - assert!(result.is_valid(), "ucumUnit should be valid for {}", type_name); + assert!( + result.is_valid(), + "ucumUnit should be valid for {}", + type_name + ); } } #[test] -#[ignore = "Pending ucumUnit keyword enforcement in the Rust schema validator"] fn test_invalid_ucum_unit_schemas() { let validator = SchemaValidator::new(); let schemas = [ @@ -922,7 +989,6 @@ fn test_invalid_ucum_unit_schemas() { } #[test] -#[ignore = "Pending JSONStructureRelations extension support in the Rust schema validator"] fn test_valid_relations_schemas() { let validator = SchemaValidator::new(); let schemas = [ @@ -992,7 +1058,6 @@ fn test_valid_relations_schemas() { } #[test] -#[ignore = "Pending JSONStructureRelations extension support in the Rust schema validator"] fn test_invalid_relations_schemas() { let validator = SchemaValidator::new(); let schemas = [ From ffa3044e3c7189cc17e2b398bc4d6f4c1ac9a5c6 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:14:03 +0200 Subject: [PATCH 07/20] Java: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validation/SchemaValidator.java | 185 +++++++++++++++++- .../RelationsAndUcumUnitValidationTests.java | 4 - 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index c907af6..f857951 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -100,7 +100,9 @@ public final class SchemaValidator { // Alternate names "altnames", // Units - "unit" + "unit", "ucumUnit", + // Relations + "identity", "relations" ); private static final Pattern NAMESPACE_PATTERN = Pattern.compile( @@ -129,6 +131,11 @@ public final class SchemaValidator { "has", "default" ); + private static final Set NUMERIC_UNIT_TYPES = Set.of( + "number", "integer", "float", "double", "decimal", + "int32", "uint32", "int64", "uint64", "int128", "uint128" + ); + /** * Internal context for a single validation operation. * This isolates mutable state per validation call, enabling thread safety. @@ -584,6 +591,9 @@ private void validateSchemaCore(JsonNode node, ValidationResult result, String p if (schema.has("altnames")) { validateAltnames(schema.get("altnames"), path, result); } + + validateUcumUnit(schema, typeStr, path, result); + validateRelationsKeywords(schema, typeStr, path, result); // A schema must have at least one schema-defining keyword (type, allOf, anyOf, oneOf, $extends) // unless it only defines definitions (a pure definition container) or uses conditional keywords @@ -1361,6 +1371,179 @@ private void validatePositiveNumber(ObjectNode schema, String keyword, String pa } } + private boolean hasExtension(JsonNode schema, String extensionName) { + JsonNode uses = schema.get("$uses"); + if (uses != null && uses.isArray()) { + for (JsonNode use : uses) { + if (use.isTextual() && extensionName.equals(use.asText())) { + return true; + } + } + } + return false; + } + + private void validateUcumUnit(ObjectNode schema, String typeStr, String path, ValidationResult result) { + if (!schema.has("ucumUnit")) { + return; + } + + if (!hasExtension(schema, "JSONStructureUnits")) { + addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "ucumUnit requires 'JSONStructureUnits' in $uses", appendPath(path, "ucumUnit")); + } + + JsonNode ucumUnit = schema.get("ucumUnit"); + if (!ucumUnit.isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "ucumUnit must be a string", appendPath(path, "ucumUnit")); + } + + if (!NUMERIC_UNIT_TYPES.contains(typeStr)) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, + "ucumUnit is only valid for numeric types", appendPath(path, "ucumUnit")); + } + } + + private void validateRelationsKeywords(ObjectNode schema, String typeStr, String path, ValidationResult result) { + boolean hasIdentity = schema.has("identity"); + boolean hasRelations = schema.has("relations"); + if (!hasIdentity && !hasRelations) { + return; + } + + boolean relationsEnabled = hasExtension(schema, "JSONStructureRelations"); + if (hasIdentity && !relationsEnabled) { + addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "identity requires 'JSONStructureRelations' in $uses", appendPath(path, "identity")); + } + if (hasRelations && !relationsEnabled) { + addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "relations requires 'JSONStructureRelations' in $uses", appendPath(path, "relations")); + } + + boolean supportsRelations = "object".equals(typeStr) || "tuple".equals(typeStr); + + if (hasIdentity) { + JsonNode identity = schema.get("identity"); + if (!supportsRelations) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, + "identity is only valid for object or tuple types", appendPath(path, "identity")); + } + + if (!identity.isArray()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "identity must be an array of strings", appendPath(path, "identity")); + } else { + Set declaredProperties = new HashSet<>(); + if (schema.has("properties") && schema.get("properties").isObject()) { + Iterator fieldNames = schema.get("properties").fieldNames(); + while (fieldNames.hasNext()) { + declaredProperties.add(fieldNames.next()); + } + } + + for (int i = 0; i < identity.size(); i++) { + JsonNode item = identity.get(i); + String identityItemPath = appendPath(appendPath(path, "identity"), String.valueOf(i)); + if (!item.isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "identity items must be strings", identityItemPath); + continue; + } + + String propertyName = item.asText(); + if (!declaredProperties.contains(propertyName)) { + addError(result, ErrorCodes.SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED, + "Identity property '" + propertyName + "' is not defined in properties", identityItemPath); + } + } + } + } + + if (!hasRelations) { + return; + } + + if (!supportsRelations) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, + "relations is only valid for object or tuple types", appendPath(path, "relations")); + } + + JsonNode relations = schema.get("relations"); + if (!relations.isObject()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "relations must be an object", appendPath(path, "relations")); + return; + } + + Iterator> fields = relations.fields(); + while (fields.hasNext()) { + Map.Entry relation = fields.next(); + String relationPath = appendPath(appendPath(path, "relations"), relation.getKey()); + JsonNode relationValue = relation.getValue(); + if (!relationValue.isObject()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "relation declarations must be objects", relationPath); + continue; + } + + ObjectNode relationObject = (ObjectNode) relationValue; + validateRelationRefObject(relationObject, "targettype", relationPath, result); + + if (!relationObject.has("cardinality")) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "cardinality is required", appendPath(relationPath, "cardinality")); + } else if (!relationObject.get("cardinality").isTextual() + || (!"single".equals(relationObject.get("cardinality").asText()) + && !"multiple".equals(relationObject.get("cardinality").asText()))) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "cardinality must be 'single' or 'multiple'", appendPath(relationPath, "cardinality")); + } + + if (relationObject.has("scope")) { + JsonNode scope = relationObject.get("scope"); + if (scope.isTextual()) { + // Valid + } else if (scope.isArray()) { + for (int i = 0; i < scope.size(); i++) { + if (!scope.get(i).isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "scope array items must be strings", + appendPath(appendPath(relationPath, "scope"), String.valueOf(i))); + } + } + } else { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + "scope must be a string or array of strings", appendPath(relationPath, "scope")); + } + } + + if (relationObject.has("qualifiertype")) { + validateRelationRefObject(relationObject, "qualifiertype", relationPath, result); + } + } + } + + private void validateRelationRefObject(ObjectNode relationObject, String keyword, String relationPath, ValidationResult result) { + String keywordPath = appendPath(relationPath, keyword); + if (!relationObject.has(keyword)) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + keyword + " is required", keywordPath); + return; + } + + JsonNode refNode = relationObject.get(keyword); + if (!refNode.isObject() || !refNode.has("$ref") || !refNode.get("$ref").isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + keyword + " must be an object with a $ref string", keywordPath); + return; + } + + validateReference(refNode.get("$ref"), "$ref", keywordPath, result); + validateRefResolution(refNode.get("$ref").asText(), appendPath(keywordPath, "$ref"), result); + } + private static String appendPath(String basePath, String segment) { if (basePath.isEmpty()) { return "/" + segment; diff --git a/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java b/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java index 18c3736..3dc1d58 100644 --- a/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java +++ b/java/src/test/java/org/json_structure/validation/RelationsAndUcumUnitValidationTests.java @@ -4,7 +4,6 @@ package org.json_structure.validation; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -80,7 +79,6 @@ void validExtendedNumericTypesWithUcumUnit(String typeName) { assertThat(result.getErrors()).isEmpty(); } - @Disabled("Pending ucumUnit keyword enforcement in the Java schema validator") @Test @DisplayName("Invalid non-numeric type with ucumUnit") void invalidNonNumericTypeWithUcumUnit() { @@ -99,7 +97,6 @@ void invalidNonNumericTypeWithUcumUnit() { assertThat(result.isValid()).isFalse(); } - @Disabled("Pending ucumUnit keyword enforcement in the Java schema validator") @Test @DisplayName("Invalid non-string ucumUnit values") void invalidNonStringUcumUnitValues() { @@ -316,7 +313,6 @@ void validRelationWithQualifierType() { assertThat(result.isValid()).isTrue(); } - @Disabled("Pending Relations keyword enforcement in the Java schema validator") @Test @DisplayName("Invalid Relations extension schemas") void invalidRelationsSchemas() { From 0c4f1feee39411985add0124463112b810953b3b Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:14:03 +0200 Subject: [PATCH 08/20] Perl: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- perl/lib/JSON/Structure/SchemaValidator.pm | 223 +++++++++++++++++++++ perl/t/02_schema_validator.t | 209 ++++++++++++++++++- 2 files changed, 428 insertions(+), 4 deletions(-) diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 2706f2f..796bdec 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -65,6 +65,7 @@ my %RESERVED_KEYWORDS = map { $_ => 1 } qw( description enum examples format items maxLength name precision properties required scale type values choices selector tuple + unit ucumUnit identity relations ); # Extended keywords for conditional composition @@ -113,6 +114,7 @@ my %VALID_FORMATS = map { $_ => 1 } qw( my %KNOWN_EXTENSIONS = map { $_ => 1 } qw( JSONStructureImport JSONStructureAlternateNames JSONStructureUnits JSONStructureConditionalComposition JSONStructureValidation + JSONStructureRelations ); sub new { @@ -1364,6 +1366,224 @@ sub _validate_extends { } } +sub _has_enabled_extension { + my ( $self, $extension ) = @_; + return !!$self->{enabled_extensions}{$extension}; +} + +sub _validate_ucum_unit_keyword { + my ( $self, $schema, $type, $path ) = @_; + + return if !exists $schema->{ucumUnit}; + + if ( !$self->_has_enabled_extension('JSONStructureUnits') ) { + $self->_add_error( + SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "'ucumUnit' requires JSONStructureUnits extension.", + "$path/ucumUnit" + ); + } + + if ( !defined $schema->{ucumUnit} || ref( $schema->{ucumUnit} ) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'ucumUnit' must be a string.", + "$path/ucumUnit" + ); + } + + my %numeric_types = map { $_ => 1 } + qw(number integer float double decimal int32 uint32 int64 uint64 int128 uint128); + if ( !defined $type || ref($type) || !$numeric_types{$type} ) { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'ucumUnit' can only appear in numeric schemas.", + "$path/ucumUnit" + ); + } +} + +sub _validate_relations_keywords { + my ( $self, $schema, $type, $path ) = @_; + + my $has_identity = exists $schema->{identity}; + my $has_relations = exists $schema->{relations}; + return if !$has_identity && !$has_relations; + + if ( !$self->_has_enabled_extension('JSONStructureRelations') ) { + if ($has_identity) { + $self->_add_error( + SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "'identity' requires JSONStructureRelations extension.", + "$path/identity" + ); + } + if ($has_relations) { + $self->_add_error( + SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "'relations' requires JSONStructureRelations extension.", + "$path/relations" + ); + } + } + + my $supports_relations = defined $type && !ref($type) && ( $type eq 'object' || $type eq 'tuple' ); + + if ($has_identity) { + my $identity = $schema->{identity}; + if ( !$supports_relations ) { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'identity' can only appear in object or tuple schemas.", + "$path/identity" + ); + } + + if ( ref($identity) ne 'ARRAY' ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'identity' must be an array of strings.", + "$path/identity" + ); + } + else { + my $properties = ref( $schema->{properties} ) eq 'HASH' ? $schema->{properties} : {}; + for my $i ( 0 .. $#$identity ) { + my $item = $identity->[$i]; + my $item_path = "$path/identity[$i]"; + if ( !defined $item || ref($item) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'identity[$i]' must be a string.", + $item_path + ); + next; + } + + if ( !exists $properties->{$item} ) { + $self->_add_error( + SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED, + "'identity' references property '$item' that is not in 'properties'.", + $item_path + ); + } + } + } + } + + return if !$has_relations; + + if ( !$supports_relations ) { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'relations' can only appear in object or tuple schemas.", + "$path/relations" + ); + } + + my $relations = $schema->{relations}; + if ( ref($relations) ne 'HASH' ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'relations' must be an object.", + "$path/relations" + ); + return; + } + + for my $relation_name ( keys %$relations ) { + my $relation = $relations->{$relation_name}; + my $relation_path = "$path/relations/$relation_name"; + + if ( ref($relation) ne 'HASH' ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + 'Relation declaration must be an object.', + $relation_path + ); + next; + } + + if ( !exists $relation->{targettype} ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "Relation declaration must have 'targettype'.", + "$relation_path/targettype" + ); + } + else { + my $targettype = $relation->{targettype}; + if ( ref($targettype) ne 'HASH' || !exists $targettype->{'$ref'} ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'targettype' must be an object with '\$ref'.", + "$relation_path/targettype" + ); + } + else { + $self->_validate_ref( $targettype->{'$ref'}, "$relation_path/targettype/\$ref" ); + } + } + + if ( !exists $relation->{cardinality} ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "Relation declaration must have 'cardinality'.", + "$relation_path/cardinality" + ); + } + else { + my $cardinality = $relation->{cardinality}; + if ( !defined $cardinality || ref($cardinality) || ( $cardinality ne 'single' && $cardinality ne 'multiple' ) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'cardinality' must be 'single' or 'multiple'.", + "$relation_path/cardinality" + ); + } + } + + if ( exists $relation->{scope} ) { + my $scope = $relation->{scope}; + if ( !ref($scope) ) { + # Valid string scope + } + elsif ( ref($scope) eq 'ARRAY' ) { + for my $i ( 0 .. $#$scope ) { + if ( !defined $scope->[$i] || ref( $scope->[$i] ) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'scope' array items must be strings.", + "$relation_path/scope[$i]" + ); + } + } + } + else { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'scope' must be a string or an array of strings.", + "$relation_path/scope" + ); + } + } + + if ( exists $relation->{qualifiertype} ) { + my $qualifiertype = $relation->{qualifiertype}; + if ( ref($qualifiertype) ne 'HASH' || !exists $qualifiertype->{'$ref'} ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'qualifiertype' must be an object with '\$ref'.", + "$relation_path/qualifiertype" + ); + } + else { + $self->_validate_ref( $qualifiertype->{'$ref'}, "$relation_path/qualifiertype/\$ref" ); + } + } + } +} + sub _check_constraint_type_mismatch { my ( $self, $schema, $type, $path ) = @_; @@ -1407,6 +1627,9 @@ sub _validate_extended_keywords { $type //= ''; + $self->_validate_ucum_unit_keyword( $schema, $type, $path ); + $self->_validate_relations_keywords( $schema, $type, $path ); + # Check constraint-type mismatches $self->_check_constraint_type_mismatch( $schema, $type, $path ); diff --git a/perl/t/02_schema_validator.t b/perl/t/02_schema_validator.t index 5c1b34d..3a75164 100644 --- a/perl/t/02_schema_validator.t +++ b/perl/t/02_schema_validator.t @@ -377,12 +377,213 @@ subtest 'ucumUnit validation' => sub { } }; -subtest 'ucumUnit invalid cases (pending)' => sub { - plan skip_all => 'Pending ucumUnit keyword enforcement in the Perl schema validator'; +subtest 'ucumUnit invalid cases' => sub { + my $validator = JSON::Structure::SchemaValidator->new(extended => 1); + + my @schemas = ( + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_string', + name => 'TextWithUnit', + '$uses' => ['JSONStructureUnits'], + type => 'string', + ucumUnit => 'm', + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_number_value', + name => 'NumericUcumUnit', + '$uses' => ['JSONStructureUnits'], + type => 'number', + ucumUnit => 42, + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_array_value', + name => 'ArrayUcumUnit', + '$uses' => ['JSONStructureUnits'], + type => 'number', + ucumUnit => ['m'], + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/ucum_object_value', + name => 'ObjectUcumUnit', + '$uses' => ['JSONStructureUnits'], + type => 'number', + ucumUnit => { code => 'm' }, + }, + ); + + for my $schema (@schemas) { + my $result = $validator->validate($schema); + ok(!$result->is_valid, 'invalid ucumUnit schema fails') or diag(join("\n", map { $_->to_string } @{$result->errors})); + } }; -subtest 'Relations extension (pending)' => sub { - plan skip_all => 'Pending JSONStructureRelations extension support in the Perl schema validator'; +subtest 'Relations extension' => sub { + my $validator = JSON::Structure::SchemaValidator->new(extended => 1); + + my @valid_schemas = ( + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_identity', + name => 'OrderIdentity', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { + id => { type => 'string' }, + tenantId => { type => 'string' }, + }, + identity => ['id', 'tenantId'], + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_declarations', + name => 'OrderRelations', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { + id => { type => 'string' }, + customerId => { type => 'string' }, + itemIds => { type => 'array', items => { type => 'string' } }, + qualifier => { type => 'string' }, + }, + relations => { + customer => { + cardinality => 'single', + targettype => { '$ref' => '#/definitions/Customer' }, + }, + items => { + cardinality => 'multiple', + targettype => { '$ref' => '#/definitions/Item' }, + scope => 'line-items', + }, + qualifiedCustomer => { + cardinality => 'single', + targettype => { '$ref' => '#/definitions/Customer' }, + qualifiertype => { '$ref' => '#/definitions/RelationQualifier' }, + }, + }, + definitions => { + Customer => { + name => 'Customer', + type => 'object', + properties => { id => { type => 'string' } }, + }, + Item => { + name => 'Item', + type => 'object', + properties => { id => { type => 'string' } }, + }, + RelationQualifier => { + name => 'RelationQualifier', + type => 'string', + }, + }, + }, + ); + + for my $schema (@valid_schemas) { + my $result = $validator->validate($schema); + ok($result->is_valid, 'valid Relations schema passes') or diag(join("\n", map { $_->to_string } @{$result->errors})); + } + + my @invalid_schemas = ( + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_identity_non_object', + name => 'IdentityOnString', + '$uses' => ['JSONStructureRelations'], + type => 'string', + identity => ['id'], + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_identity_not_array', + name => 'IdentityNotArray', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { id => { type => 'string' } }, + identity => 'id', + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_identity_missing_property', + name => 'IdentityMissingProperty', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { id => { type => 'string' } }, + identity => ['missing'], + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_non_object', + name => 'RelationsOnString', + '$uses' => ['JSONStructureRelations'], + type => 'string', + relations => {}, + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_invalid_cardinality', + name => 'InvalidCardinality', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { id => { type => 'string' } }, + relations => { + customer => { + cardinality => 'many', + targettype => { '$ref' => '#/definitions/Customer' }, + }, + }, + definitions => { + Customer => { + name => 'Customer', + type => 'object', + properties => { id => { type => 'string' } }, + }, + }, + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_missing_targettype', + name => 'MissingTargettype', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { id => { type => 'string' } }, + relations => { + customer => { + cardinality => 'single', + }, + }, + }, + { + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/schema/relations_missing_cardinality', + name => 'MissingCardinality', + '$uses' => ['JSONStructureRelations'], + type => 'object', + properties => { id => { type => 'string' } }, + relations => { + customer => { + targettype => { '$ref' => '#/definitions/Customer' }, + }, + }, + definitions => { + Customer => { + name => 'Customer', + type => 'object', + properties => { id => { type => 'string' } }, + }, + }, + }, + ); + + for my $schema (@invalid_schemas) { + my $result = $validator->validate($schema); + ok(!$result->is_valid, 'invalid Relations schema fails') or diag(join("\n", map { $_->to_string } @{$result->errors})); + } }; done_testing(); From d30595b65f46d621e7a80991b634585db54f683b Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:23:45 +0200 Subject: [PATCH 09/20] Ruby: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ruby/lib/jsonstructure/schema_validator.rb | 256 ++++++++++++++++++--- ruby/spec/schema_validator_spec.rb | 62 ++++- 2 files changed, 284 insertions(+), 34 deletions(-) diff --git a/ruby/lib/jsonstructure/schema_validator.rb b/ruby/lib/jsonstructure/schema_validator.rb index db5c5b4..bf7d3f5 100644 --- a/ruby/lib/jsonstructure/schema_validator.rb +++ b/ruby/lib/jsonstructure/schema_validator.rb @@ -1,11 +1,17 @@ # frozen_string_literal: true +require 'json' + module JsonStructure # Validates JSON Structure schema documents # # This class is thread-safe. Multiple threads can call validate concurrently. class SchemaValidator - # Validate a schema string + UCUM_NUMERIC_TYPES = %w[number integer float double decimal int32 uint32 int64 uint64 int128 uint128].freeze + RELATION_CONTAINER_TYPES = %w[object tuple].freeze + + class << self + # Validate a schema string # # This method is thread-safe and can be called from multiple threads concurrently. # @@ -20,39 +26,227 @@ class SchemaValidator # else # result.errors.each { |e| puts e.message } # end - def self.validate(schema_json) - raise ArgumentError, 'schema_json must be a String' unless schema_json.is_a?(String) - - JsonStructure.validation_started - begin - result_ptr = ::FFI::MemoryPointer.new(FFI::JSResult.size) - FFI.js_result_init(result_ptr) - - FFI.js_validate_schema(schema_json, result_ptr) - ValidationResult.from_ffi(result_ptr) - ensure - JsonStructure.validation_completed + def validate(schema_json) + raise ArgumentError, 'schema_json must be a String' unless schema_json.is_a?(String) + + JsonStructure.validation_started + begin + result_ptr = ::FFI::MemoryPointer.new(FFI::JSResult.size) + FFI.js_result_init(result_ptr) + + FFI.js_validate_schema(schema_json, result_ptr) + base_result = ValidationResult.from_ffi(result_ptr) + augment_extension_validation(base_result, schema_json) + ensure + JsonStructure.validation_completed + end end - end - # Validate a schema string, raising an exception on failure - # - # @param schema_json [String] JSON string containing the schema - # @return [ValidationResult] validation result (only if valid) - # @raise [SchemaValidationError] if validation fails - # - # @example - # begin - # JsonStructure::SchemaValidator.validate!(schema) - # puts "Schema is valid!" - # rescue JsonStructure::SchemaValidationError => e - # puts "Validation failed: #{e.message}" - # end - def self.validate!(schema_json) - result = validate(schema_json) - raise SchemaValidationError.new(result) unless result.valid? + # Validate a schema string, raising an exception on failure + # + # @param schema_json [String] JSON string containing the schema + # @return [ValidationResult] validation result (only if valid) + # @raise [SchemaValidationError] if validation fails + # + # @example + # begin + # JsonStructure::SchemaValidator.validate!(schema) + # puts "Schema is valid!" + # rescue JsonStructure::SchemaValidationError => e + # puts "Validation failed: #{e.message}" + # end + def validate!(schema_json) + result = validate(schema_json) + raise SchemaValidationError.new(result) unless result.valid? + + result + end + + private + + def augment_extension_validation(base_result, schema_json) + schema = JSON.parse(schema_json) + additional_errors = [] + validate_extension_keywords(schema, schema, '#', additional_errors) + + return base_result if additional_errors.empty? + + errors = base_result.errors + additional_errors + ValidationResult.new(errors.none?(&:error?), errors) + rescue JSON::ParserError + base_result + end + + def validate_extension_keywords(root_schema, node, path, errors) + return unless node.is_a?(Hash) + + type = node['type'] + validate_ucum_unit_keyword(root_schema, node, type, path, errors) + validate_relations_keywords(root_schema, node, type, path, errors) + + node.each do |key, value| + child_path = path == '#' ? "#/#{escape_json_pointer(key)}" : "#{path}/#{escape_json_pointer(key)}" + + if value.is_a?(Hash) + validate_extension_keywords(root_schema, value, child_path, errors) + elsif value.is_a?(Array) + value.each_with_index do |item, index| + validate_extension_keywords(root_schema, item, "#{child_path}[#{index}]", errors) + end + end + end + end + + def validate_ucum_unit_keyword(root_schema, node, type, path, errors) + return unless node.key?('ucumUnit') + + add_manual_error(errors, "'ucumUnit' requires JSONStructureUnits extension.", "#{path}/ucumUnit") unless extension_enabled?(root_schema, 'JSONStructureUnits') + + add_manual_error(errors, "'ucumUnit' must be a string.", "#{path}/ucumUnit") unless node['ucumUnit'].is_a?(String) - result + return if type.is_a?(String) && UCUM_NUMERIC_TYPES.include?(type) + + add_manual_error(errors, "'ucumUnit' can only appear in numeric schemas.", "#{path}/ucumUnit") + end + + def validate_relations_keywords(root_schema, node, type, path, errors) + has_identity = node.key?('identity') + has_relations = node.key?('relations') + return unless has_identity || has_relations + + unless extension_enabled?(root_schema, 'JSONStructureRelations') + add_manual_error(errors, "'identity' requires JSONStructureRelations extension.", "#{path}/identity") if has_identity + add_manual_error(errors, "'relations' requires JSONStructureRelations extension.", "#{path}/relations") if has_relations + end + + supports_relations = type.is_a?(String) && RELATION_CONTAINER_TYPES.include?(type) + + if has_identity + validate_identity_keyword(node, path, supports_relations, errors) + end + + validate_relations_object(root_schema, node, path, supports_relations, errors) if has_relations + end + + def validate_identity_keyword(node, path, supports_relations, errors) + identity = node['identity'] + add_manual_error(errors, "'identity' can only appear in object or tuple schemas.", "#{path}/identity") unless supports_relations + + unless identity.is_a?(Array) + add_manual_error(errors, "'identity' must be an array of strings.", "#{path}/identity") + return + end + + properties = node['properties'].is_a?(Hash) ? node['properties'] : {} + identity.each_with_index do |item, index| + item_path = "#{path}/identity[#{index}]" + unless item.is_a?(String) + add_manual_error(errors, "'identity[#{index}]' must be a string.", item_path) + next + end + + unless properties.key?(item) + add_manual_error(errors, "'identity' references property '#{item}' that is not in 'properties'.", item_path) + end + end + end + + def validate_relations_object(root_schema, node, path, supports_relations, errors) + relations = node['relations'] + add_manual_error(errors, "'relations' can only appear in object or tuple schemas.", "#{path}/relations") unless supports_relations + + unless relations.is_a?(Hash) + add_manual_error(errors, "'relations' must be an object.", "#{path}/relations") + return + end + + relations.each do |relation_name, relation| + relation_path = "#{path}/relations/#{escape_json_pointer(relation_name.to_s)}" + + unless relation.is_a?(Hash) + add_manual_error(errors, 'Relation declaration must be an object.', relation_path) + next + end + + if relation.key?('targettype') + validate_relation_ref_object(root_schema, relation['targettype'], relation_path, 'targettype', errors) + else + add_manual_error(errors, "Relation declaration must have 'targettype'.", "#{relation_path}/targettype") + end + + if relation.key?('cardinality') + cardinality = relation['cardinality'] + unless cardinality.is_a?(String) && %w[single multiple].include?(cardinality) + add_manual_error(errors, "'cardinality' must be 'single' or 'multiple'.", "#{relation_path}/cardinality") + end + else + add_manual_error(errors, "Relation declaration must have 'cardinality'.", "#{relation_path}/cardinality") + end + + validate_relation_scope(relation['scope'], relation_path, errors) if relation.key?('scope') + validate_relation_ref_object(root_schema, relation['qualifiertype'], relation_path, 'qualifiertype', errors) if relation.key?('qualifiertype') + end + end + + def validate_relation_scope(scope, relation_path, errors) + return if scope.is_a?(String) + + if scope.is_a?(Array) + scope.each_with_index do |item, index| + next if item.is_a?(String) + + add_manual_error(errors, "'scope' array items must be strings.", "#{relation_path}/scope[#{index}]") + end + return + end + + add_manual_error(errors, "'scope' must be a string or an array of strings.", "#{relation_path}/scope") + end + + def validate_relation_ref_object(root_schema, value, relation_path, keyword, errors) + keyword_path = "#{relation_path}/#{keyword}" + + unless value.is_a?(Hash) && value['$ref'].is_a?(String) + add_manual_error(errors, "'#{keyword}' must be an object with '$ref'.", keyword_path) + return + end + + ref = value['$ref'] + return unless ref.start_with?('#/') + return if resolve_ref(root_schema, ref) + + add_manual_error(errors, "$ref '#{ref}' not found", "#{keyword_path}/$ref") + end + + def extension_enabled?(root_schema, extension) + uses = root_schema['$uses'] + uses.is_a?(Array) && uses.include?(extension) + end + + def resolve_ref(root_schema, ref) + return nil unless ref.start_with?('#/') + + ref.delete_prefix('#/').split('/').reduce(root_schema) do |current, segment| + segment = segment.gsub('~1', '/').gsub('~0', '~') + break nil unless current.is_a?(Hash) && current.key?(segment) + + current[segment] + end + end + + def escape_json_pointer(segment) + segment.to_s.gsub('~', '~0').gsub('/', '~1') + end + + def add_manual_error(errors, message, path) + errors << ValidationError.new( + code: 0, + severity: FFI::JS_SEVERITY_ERROR, + path: path, + message: message, + location: { line: 0, column: 0, offset: 0 } + ) + end end end diff --git a/ruby/spec/schema_validator_spec.rb b/ruby/spec/schema_validator_spec.rb index e24c099..57b965c 100644 --- a/ruby/spec/schema_validator_spec.rb +++ b/ruby/spec/schema_validator_spec.rb @@ -147,11 +147,39 @@ end it 'rejects ucumUnit on non-numeric types' do - skip 'Pending ucumUnit keyword enforcement in the Ruby schema validator' + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-string", + "name": "BadUcumType", + "$uses": ["JSONStructureUnits"], + "type": "string", + "ucumUnit": "m" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.error_messages).to include("'ucumUnit' can only appear in numeric schemas.") end it 'rejects non-string ucumUnit values' do - skip 'Pending ucumUnit keyword enforcement in the Ruby schema validator' + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-non-string", + "name": "BadUcumValue", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": 5 + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.error_messages).to include("'ucumUnit' must be a string.") end end @@ -234,7 +262,35 @@ end it 'rejects invalid Relations schemas' do - skip 'Pending Relations keyword enforcement in the Ruby schema validator' + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-invalid", + "name": "BadRelations", + "$uses": ["JSONStructureRelations"], + "type": "string", + "identity": ["id"], + "relations": { + "customer": { + "cardinality": "many", + "targettype": { "type": "object" }, + "scope": ["ok", 3], + "qualifiertype": { "type": "string" } + } + } + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.error_messages).to include("'identity' can only appear in object or tuple schemas.") + expect(result.error_messages).to include("'identity' references property 'id' that is not in 'properties'.") + expect(result.error_messages).to include("'relations' can only appear in object or tuple schemas.") + expect(result.error_messages).to include("'targettype' must be an object with '$ref'.") + expect(result.error_messages).to include("'cardinality' must be 'single' or 'multiple'.") + expect(result.error_messages).to include("'scope' array items must be strings.") + expect(result.error_messages).to include("'qualifiertype' must be an object with '$ref'.") end end end From 173d315f3d127d0c6bf604203e5a8b09da1b1544 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:23:51 +0200 Subject: [PATCH 10/20] PHP: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- php/src/JsonStructure/SchemaValidator.php | 140 +++++++++++++++ php/src/JsonStructure/Types.php | 1 + php/tests/SchemaValidatorTest.php | 205 +++++++++++++++++++++- 3 files changed, 338 insertions(+), 8 deletions(-) diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index 04d4fef..37e6fc8 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -359,6 +359,10 @@ private function validateSchema( } } + $typeName = is_string($schemaObj['type'] ?? null) ? $schemaObj['type'] : null; + $this->checkUcumUnitKeyword($schemaObj, $typeName, $path); + $this->checkRelationsKeywords($schemaObj, $typeName, $path); + // Extended validation checks if ($this->extended && isset($schemaObj['type'])) { $this->checkExtendedValidationKeywords($schemaObj, $path); @@ -493,6 +497,142 @@ private function checkCompositionKeywords(array $obj, string $path): void } } + private function hasEnabledExtension(string $extension): bool + { + return in_array($extension, $this->enabledExtensions, true); + } + + private function checkUcumUnitKeyword(array $obj, ?string $typeName, string $path): void + { + if (!array_key_exists('ucumUnit', $obj)) { + return; + } + + if (!$this->hasEnabledExtension('JSONStructureUnits')) { + $this->addError("'ucumUnit' requires JSONStructureUnits extension.", "{$path}/ucumUnit", ErrorCodes::SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED); + } + + if (!is_string($obj['ucumUnit'])) { + $this->addError("'ucumUnit' must be a string.", "{$path}/ucumUnit", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } + + $allowedTypes = ['number', 'integer', 'float', 'double', 'decimal', 'int32', 'uint32', 'int64', 'uint64', 'int128', 'uint128']; + if ($typeName === null || !in_array($typeName, $allowedTypes, true)) { + $this->addError("'ucumUnit' can only appear in numeric schemas.", "{$path}/ucumUnit", ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + } + + private function checkRelationsKeywords(array $obj, ?string $typeName, string $path): void + { + $hasIdentity = array_key_exists('identity', $obj); + $hasRelations = array_key_exists('relations', $obj); + if (!$hasIdentity && !$hasRelations) { + return; + } + + if (!$this->hasEnabledExtension('JSONStructureRelations')) { + if ($hasIdentity) { + $this->addError("'identity' requires JSONStructureRelations extension.", "{$path}/identity", ErrorCodes::SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED); + } + if ($hasRelations) { + $this->addError("'relations' requires JSONStructureRelations extension.", "{$path}/relations", ErrorCodes::SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED); + } + } + + $supportsRelations = in_array($typeName, ['object', 'tuple'], true); + + if ($hasIdentity) { + $identity = $obj['identity']; + if (!$supportsRelations) { + $this->addError("'identity' can only appear in object or tuple schemas.", "{$path}/identity", ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + + if (!is_array($identity)) { + $this->addError("'identity' must be an array of strings.", "{$path}/identity", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } else { + $properties = isset($obj['properties']) && is_array($obj['properties']) ? $obj['properties'] : []; + foreach ($identity as $idx => $item) { + $itemPath = "{$path}/identity[{$idx}]"; + if (!is_string($item)) { + $this->addError("'identity[{$idx}]' must be a string.", $itemPath, ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + continue; + } + + if (!array_key_exists($item, $properties)) { + $this->addError("'identity' references property '{$item}' that is not in 'properties'.", $itemPath, ErrorCodes::SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED); + } + } + } + } + + if (!$hasRelations) { + return; + } + + $relations = $obj['relations']; + if (!$supportsRelations) { + $this->addError("'relations' can only appear in object or tuple schemas.", "{$path}/relations", ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + + if (!is_array($relations) || array_is_list($relations)) { + $this->addError("'relations' must be an object.", "{$path}/relations", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + return; + } + + foreach ($relations as $relationName => $relation) { + $relationPath = "{$path}/relations/{$relationName}"; + if (!is_array($relation) || array_is_list($relation)) { + $this->addError('Relation declaration must be an object.', $relationPath, ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + continue; + } + + if (!array_key_exists('targettype', $relation)) { + $this->addError("Relation declaration must have 'targettype'.", "{$relationPath}/targettype", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } else { + $this->checkRelationRefObject($relation['targettype'], 'targettype', $relationPath); + } + + if (!array_key_exists('cardinality', $relation)) { + $this->addError("Relation declaration must have 'cardinality'.", "{$relationPath}/cardinality", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } else { + $cardinality = $relation['cardinality']; + if (!is_string($cardinality) || !in_array($cardinality, ['single', 'multiple'], true)) { + $this->addError("'cardinality' must be 'single' or 'multiple'.", "{$relationPath}/cardinality", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } + } + + if (array_key_exists('scope', $relation)) { + $scope = $relation['scope']; + if (is_string($scope)) { + // Valid. + } elseif (is_array($scope) && array_is_list($scope)) { + foreach ($scope as $idx => $item) { + if (!is_string($item)) { + $this->addError("'scope' array items must be strings.", "{$relationPath}/scope[{$idx}]", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } + } + } else { + $this->addError("'scope' must be a string or an array of strings.", "{$relationPath}/scope", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } + } + + if (array_key_exists('qualifiertype', $relation)) { + $this->checkRelationRefObject($relation['qualifiertype'], 'qualifiertype', $relationPath); + } + } + } + + private function checkRelationRefObject(mixed $value, string $keyword, string $relationPath): void + { + $keywordPath = "{$relationPath}/{$keyword}"; + if (!is_array($value) || array_is_list($value) || !array_key_exists('$ref', $value)) { + $this->addError("'{$keyword}' must be an object with '\$ref'.", $keywordPath, ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + return; + } + + $this->checkJsonPointer($value['$ref'], $this->doc, "{$keywordPath}/$ref"); + } + private function checkExtendedValidationKeywords(array $obj, string $path): void { $validationEnabled = in_array('JSONStructureValidation', $this->enabledExtensions, true); diff --git a/php/src/JsonStructure/Types.php b/php/src/JsonStructure/Types.php index 0f7ce08..dbd481e 100644 --- a/php/src/JsonStructure/Types.php +++ b/php/src/JsonStructure/Types.php @@ -172,6 +172,7 @@ final class Types 'JSONStructureImport', 'JSONStructureAlternateNames', 'JSONStructureUnits', + 'JSONStructureRelations', 'JSONStructureConditionalComposition', 'JSONStructureValidation', ]; diff --git a/php/tests/SchemaValidatorTest.php b/php/tests/SchemaValidatorTest.php index e556d8b..c1609e2 100644 --- a/php/tests/SchemaValidatorTest.php +++ b/php/tests/SchemaValidatorTest.php @@ -707,41 +707,230 @@ public function testValidExtendedNumericTypesWithUcumUnit(): void public function testInvalidNonNumericTypeWithUcumUnitIsPending(): void { - $this->markTestSkipped('Pending ucumUnit keyword enforcement in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/ucum-invalid-type.struct.json', + 'name' => 'BadUcumType', + 'type' => 'string', + 'ucumUnit' => 'm', + ]; + + $errors = $this->validator->validate($schema); + $messages = array_map(static fn ($error) => (string) $error, $errors); + + $this->assertGreaterThan(0, count($errors)); + $this->assertTrue($this->containsMessage($messages, 'JSONStructureUnits extension')); + $this->assertTrue($this->containsMessage($messages, 'can only appear in numeric schemas')); } public function testInvalidNonStringUcumUnitValuesArePending(): void { - $this->markTestSkipped('Pending ucumUnit keyword enforcement in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/ucum-invalid-value.struct.json', + 'name' => 'BadUcumValue', + '$uses' => ['JSONStructureUnits'], + 'type' => 'number', + 'ucumUnit' => 5, + ]; + + $errors = $this->validator->validate($schema); + $messages = array_map(static fn ($error) => (string) $error, $errors); + + $this->assertGreaterThan(0, count($errors)); + $this->assertTrue($this->containsMessage($messages, "'ucumUnit' must be a string.")); } public function testRelationsIdentityValidationIsPending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-identity.struct.json', + 'name' => 'OrderIdentity', + '$uses' => ['JSONStructureRelations'], + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'tenantId' => ['type' => 'string'], + ], + 'identity' => ['id', 'tenantId'], + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); } public function testRelationsDeclarationValidationIsPending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-valid.struct.json', + 'name' => 'OrderRelations', + '$uses' => ['JSONStructureRelations'], + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'customerId' => ['type' => 'string'], + ], + 'relations' => [ + 'customer' => [ + 'cardinality' => 'single', + 'targettype' => ['$ref' => '#/definitions/Customer'], + 'scope' => 'tenant', + ], + ], + 'definitions' => [ + 'Customer' => [ + 'name' => 'Customer', + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string']], + ], + ], + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); } public function testRelationsSingleCardinalityValidationIsPending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-single.struct.json', + 'name' => 'SingleRelation', + '$uses' => ['JSONStructureRelations'], + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + ], + 'relations' => [ + 'parent' => [ + 'cardinality' => 'single', + 'targettype' => ['$ref' => '#/definitions/Parent'], + ], + ], + 'definitions' => [ + 'Parent' => [ + 'name' => 'Parent', + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string']], + ], + ], + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); } public function testRelationsMultipleCardinalityValidationIsPending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-multiple.struct.json', + 'name' => 'MultipleRelation', + '$uses' => ['JSONStructureRelations'], + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + ], + 'relations' => [ + 'children' => [ + 'cardinality' => 'multiple', + 'targettype' => ['$ref' => '#/definitions/Child'], + 'scope' => ['tenant', 'region'], + ], + ], + 'definitions' => [ + 'Child' => [ + 'name' => 'Child', + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string']], + ], + ], + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); } public function testRelationsQualifierTypeValidationIsPending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-qualifier.struct.json', + 'name' => 'QualifiedRelation', + '$uses' => ['JSONStructureRelations'], + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + ], + 'relations' => [ + 'customer' => [ + 'cardinality' => 'single', + 'targettype' => ['$ref' => '#/definitions/Customer'], + 'qualifiertype' => ['$ref' => '#/definitions/RelationQualifier'], + ], + ], + 'definitions' => [ + 'Customer' => [ + 'name' => 'Customer', + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string']], + ], + 'RelationQualifier' => [ + 'name' => 'RelationQualifier', + 'type' => 'string', + ], + ], + ]; + + $errors = $this->validator->validate($schema); + $this->assertCount(0, $errors); } public function testInvalidRelationsSchemasArePending(): void { - $this->markTestSkipped('Pending JSONStructureRelations extension support in the PHP schema validator'); + $schema = [ + '$schema' => 'https://json-structure.org/meta/extended/v0/#', + '$id' => 'https://example.com/relations-invalid.struct.json', + 'name' => 'BadRelations', + 'type' => 'string', + 'identity' => ['id'], + 'relations' => [ + 'customer' => [ + 'cardinality' => 'many', + 'targettype' => ['type' => 'object'], + 'scope' => ['tenant', 3], + 'qualifiertype' => ['type' => 'string'], + ], + ], + ]; + + $errors = $this->validator->validate($schema); + $messages = array_map(static fn ($error) => (string) $error, $errors); + + $this->assertGreaterThan(0, count($errors)); + $this->assertTrue($this->containsMessage($messages, 'JSONStructureRelations extension')); + $this->assertTrue($this->containsMessage($messages, "'identity' can only appear in object or tuple schemas.")); + $this->assertTrue($this->containsMessage($messages, "'identity' references property 'id' that is not in 'properties'.")); + $this->assertTrue($this->containsMessage($messages, "'relations' can only appear in object or tuple schemas.")); + $this->assertTrue($this->containsMessage($messages, "'targettype' must be an object with '\$ref'.")); + $this->assertTrue($this->containsMessage($messages, "'cardinality' must be 'single' or 'multiple'.")); + $this->assertTrue($this->containsMessage($messages, "'scope' array items must be strings.")); + $this->assertTrue($this->containsMessage($messages, "'qualifiertype' must be an object with '\$ref'.")); + } + + /** + * @param string[] $messages + */ + private function containsMessage(array $messages, string $needle): bool + { + foreach ($messages as $message) { + if (str_contains($message, $needle)) { + return true; + } + } + + return false; } } From 9a78d8b8d49a2a065eae78bbd926af6cdaf3ce48 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:23:56 +0200 Subject: [PATCH 11/20] Swift: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JSONStructure/SchemaValidator.swift | 186 ++++++++++++++++-- .../RelationsAndUcumUnitValidationTests.swift | 57 +++++- 2 files changed, 222 insertions(+), 21 deletions(-) diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 7d7d7c7..2f109dd 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -65,6 +65,7 @@ private final class ValidationEngine { private var schema: [String: Any] = [:] private var seenRefs: Set = [] private var seenExtends: Set = [] + private var enabledExtensions: Set = [] private var sourceLocator: JsonSourceLocator? /// Validation extension keywords that require JSONStructureValidation extension. @@ -88,6 +89,7 @@ private final class ValidationEngine { warnings = [] seenRefs = [] seenExtends = [] + enabledExtensions = [] guard let schemaMap = schema as? [String: Any] else { addError("#", "Schema must be an object", schemaInvalidType) @@ -95,6 +97,9 @@ private final class ValidationEngine { } self.schema = schemaMap + if options.extended { + checkEnabledExtensions(schemaMap) + } validateSchemaDocument(schemaMap, "#") return result() @@ -188,31 +193,30 @@ private final class ValidationEngine { } } - private func checkValidationExtensionKeywords(_ schema: [String: Any]) { - // Check if warnings are enabled (default is true) - if !options.warnOnUnusedExtensionKeywords { - return + private func checkEnabledExtensions(_ schema: [String: Any]) { + enabledExtensions = [] + + if let schemaURI = schema["$schema"] as? String, + schemaURI.contains("validation") { + enabledExtensions.insert("JSONStructureConditionalComposition") + enabledExtensions.insert("JSONStructureValidation") } - - // Check if validation extensions are enabled - var validationEnabled = false - + if let uses = schema["$uses"] as? [Any] { - for u in uses { - if let uStr = u as? String, uStr == "JSONStructureValidation" { - validationEnabled = true - break + for use in uses { + if let useStr = use as? String { + enabledExtensions.insert(useStr) } } } - - if let schemaURI = schema["$schema"] as? String { - if schemaURI.contains("extended") || schemaURI.contains("validation") { - validationEnabled = true - } + } + + private func checkValidationExtensionKeywords(_ schema: [String: Any]) { + if !options.warnOnUnusedExtensionKeywords { + return } - - if !validationEnabled { + + if !enabledExtensions.contains("JSONStructureValidation") { collectValidationKeywordWarnings(schema, "") } } @@ -332,6 +336,10 @@ private final class ValidationEngine { } else { addError("\(path)/type", "type must be a string, array, or object with $ref", schemaKeywordInvalidType) } + + let typeName = schema["type"] as? String + validateUcumUnitKeyword(schema, typeName, path) + validateRelationsKeywords(schema, typeName, path) } private func validateSingleType(_ typeStr: String, _ schema: [String: Any], _ path: String) { @@ -746,6 +754,146 @@ private final class ValidationEngine { } } } + + private func validateUcumUnitKeyword(_ schema: [String: Any], _ typeName: String?, _ path: String) { + guard schema["ucumUnit"] != nil else { + return + } + + if !enabledExtensions.contains("JSONStructureUnits") { + addError("\(path)/ucumUnit", "'ucumUnit' requires JSONStructureUnits extension.", schemaExtensionKeywordNotEnabled) + } + + if !(schema["ucumUnit"] is String) { + addError("\(path)/ucumUnit", "'ucumUnit' must be a string.", schemaKeywordInvalidType) + } + + let allowedTypes: Set = ["number", "integer", "float", "double", "decimal", "int32", "uint32", "int64", "uint64", "int128", "uint128"] + if let typeName, allowedTypes.contains(typeName) { + return + } + + addError("\(path)/ucumUnit", "'ucumUnit' can only appear in numeric schemas.", schemaConstraintInvalidForType) + } + + private func validateRelationsKeywords(_ schema: [String: Any], _ typeName: String?, _ path: String) { + let hasIdentity = schema["identity"] != nil + let hasRelations = schema["relations"] != nil + guard hasIdentity || hasRelations else { + return + } + + if !enabledExtensions.contains("JSONStructureRelations") { + if hasIdentity { + addError("\(path)/identity", "'identity' requires JSONStructureRelations extension.", schemaExtensionKeywordNotEnabled) + } + if hasRelations { + addError("\(path)/relations", "'relations' requires JSONStructureRelations extension.", schemaExtensionKeywordNotEnabled) + } + } + + let supportsRelations = typeName == "object" || typeName == "tuple" + + if hasIdentity { + validateIdentityKeyword(schema, path, supportsRelations) + } + + if hasRelations { + validateRelationsObject(schema, path, supportsRelations) + } + } + + private func validateIdentityKeyword(_ schema: [String: Any], _ path: String, _ supportsRelations: Bool) { + if !supportsRelations { + addError("\(path)/identity", "'identity' can only appear in object or tuple schemas.", schemaConstraintInvalidForType) + } + + guard let identity = schema["identity"] as? [Any] else { + addError("\(path)/identity", "'identity' must be an array of strings.", schemaKeywordInvalidType) + return + } + + let properties = schema["properties"] as? [String: Any] ?? [:] + for (index, item) in identity.enumerated() { + let itemPath = "\(path)/identity[\(index)]" + guard let itemName = item as? String else { + addError(itemPath, "'identity[\(index)]' must be a string.", schemaKeywordInvalidType) + continue + } + + if properties[itemName] == nil { + addError(itemPath, "'identity' references property '\(itemName)' that is not in 'properties'.", schemaRequiredPropertyNotDefined) + } + } + } + + private func validateRelationsObject(_ schema: [String: Any], _ path: String, _ supportsRelations: Bool) { + if !supportsRelations { + addError("\(path)/relations", "'relations' can only appear in object or tuple schemas.", schemaConstraintInvalidForType) + } + + guard let relations = schema["relations"] as? [String: Any] else { + addError("\(path)/relations", "'relations' must be an object.", schemaKeywordInvalidType) + return + } + + for (relationName, relationValue) in relations { + let relationPath = "\(path)/relations/\(relationName)" + guard let relation = relationValue as? [String: Any] else { + addError(relationPath, "Relation declaration must be an object.", schemaKeywordInvalidType) + continue + } + + if relation["targettype"] != nil { + validateRelationRefObject(relation["targettype"], keyword: "targettype", relationPath: relationPath) + } else { + addError("\(relationPath)/targettype", "Relation declaration must have 'targettype'.", schemaKeywordInvalidType) + } + + if let cardinality = relation["cardinality"] as? String { + if cardinality != "single" && cardinality != "multiple" { + addError("\(relationPath)/cardinality", "'cardinality' must be 'single' or 'multiple'.", schemaKeywordInvalidType) + } + } else { + addError("\(relationPath)/cardinality", "Relation declaration must have 'cardinality'.", schemaKeywordInvalidType) + } + + if relation["scope"] != nil { + validateRelationScope(relation["scope"], relationPath: relationPath) + } + + if relation["qualifiertype"] != nil { + validateRelationRefObject(relation["qualifiertype"], keyword: "qualifiertype", relationPath: relationPath) + } + } + } + + private func validateRelationScope(_ scope: Any?, relationPath: String) { + if scope is String { + return + } + + if let scopeArray = scope as? [Any] { + for (index, item) in scopeArray.enumerated() { + if !(item is String) { + addError("\(relationPath)/scope[\(index)]", "'scope' array items must be strings.", schemaKeywordInvalidType) + } + } + return + } + + addError("\(relationPath)/scope", "'scope' must be a string or an array of strings.", schemaKeywordInvalidType) + } + + private func validateRelationRefObject(_ value: Any?, keyword: String, relationPath: String) { + let keywordPath = "\(relationPath)/\(keyword)" + guard let value = value as? [String: Any], value["$ref"] != nil else { + addError(keywordPath, "'\(keyword)' must be an object with '$ref'.", schemaKeywordInvalidType) + return + } + + validateRef(value["$ref"] as Any, "\(keywordPath)/$ref") + } private func validateExtends(_ extendsVal: Any, _ path: String) { var refs: [String] = [] diff --git a/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift b/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift index 44f9841..60f8612 100644 --- a/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift +++ b/swift/Tests/JSONStructureTests/RelationsAndUcumUnitValidationTests.swift @@ -52,7 +52,33 @@ final class RelationsAndUcumUnitValidationTests: XCTestCase { } func testInvalidUcumUnitScenariosArePending() throws { - throw XCTSkip("Pending ucumUnit keyword enforcement in the Swift schema validator") + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + + let invalidTypeSchema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-invalid-type", + "name": "BadUcumType", + "type": "string", + "ucumUnit": "m" + ] + + let invalidValueSchema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:ucum-invalid-value", + "name": "BadUcumValue", + "$uses": ["JSONStructureUnits"], + "type": "number", + "ucumUnit": 5 + ] + + let invalidTypeResult = validator.validate(invalidTypeSchema) + XCTAssertFalse(invalidTypeResult.isValid) + XCTAssertTrue(invalidTypeResult.errors.contains { $0.message.contains("JSONStructureUnits extension") }) + XCTAssertTrue(invalidTypeResult.errors.contains { $0.message.contains("can only appear in numeric schemas") }) + + let invalidValueResult = validator.validate(invalidValueSchema) + XCTAssertFalse(invalidValueResult.isValid) + XCTAssertTrue(invalidValueResult.errors.contains { $0.message.contains("'ucumUnit' must be a string.") }) } func testValidRelationsIdentityArray() throws { @@ -130,6 +156,33 @@ final class RelationsAndUcumUnitValidationTests: XCTestCase { } func testInvalidRelationsScenariosArePending() throws { - throw XCTSkip("Pending Relations keyword enforcement in the Swift schema validator") + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + + let schema: [String: Any] = [ + "$schema": "https://json-structure.org/meta/extended/v0/#", + "$id": "urn:example:relations-invalid", + "name": "BadRelations", + "type": "string", + "identity": ["id"], + "relations": [ + "customer": [ + "cardinality": "many", + "targettype": ["type": "object"], + "scope": ["tenant", 3], + "qualifiertype": ["type": "string"] + ] + ] + ] + + let result = validator.validate(schema) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.message.contains("JSONStructureRelations extension") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'identity' can only appear in object or tuple schemas.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'identity' references property 'id' that is not in 'properties'.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'relations' can only appear in object or tuple schemas.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'targettype' must be an object with '$ref'.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'cardinality' must be 'single' or 'multiple'.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'scope' array items must be strings.") }) + XCTAssertTrue(result.errors.contains { $0.message.contains("'qualifiertype' must be an object with '$ref'.") }) } } From efaf04cc6a599bd0050070fb6cc9bb843e48078d Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:24:02 +0200 Subject: [PATCH 12/20] C: implement ucumUnit + Relations enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/src/schema_validator.c | 282 ++++++++++++++++++++++++++++++++ c/tests/test_schema_validator.c | 156 ++++++++++++++++-- 2 files changed, 421 insertions(+), 17 deletions(-) diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index 8faf212..ff5e013 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -74,6 +74,9 @@ static bool validate_array_items(validate_context_t* ctx, const cJSON* schema); static bool validate_map_values(validate_context_t* ctx, const cJSON* schema); static bool validate_choice_schema(validate_context_t* ctx, const cJSON* schema); static bool validate_constraints(validate_context_t* ctx, const cJSON* schema, const char* type_name); +static bool validate_ucum_unit_keyword(validate_context_t* ctx, const cJSON* schema, const char* type_name); +static bool validate_relations_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name); +static const cJSON* resolve_ref(validate_context_t* ctx, const char* ref); static bool process_imports(import_context_t* ctx, cJSON* obj, const char* path); /* ============================================================================ @@ -119,6 +122,70 @@ static bool is_string_in_list(const char* str, const char** list) { return false; } +static bool has_enabled_extension(validate_context_t* ctx, const char* extension) { + if (!ctx || !ctx->root_schema || !extension) return false; + + const cJSON* uses = cJSON_GetObjectItemCaseSensitive(ctx->root_schema, "$uses"); + if (!uses || !cJSON_IsArray(uses)) return false; + + cJSON* item = NULL; + cJSON_ArrayForEach(item, uses) { + if (cJSON_IsString(item) && strcmp(item->valuestring, extension) == 0) { + return true; + } + } + + return false; +} + +static bool is_ucum_numeric_type(const char* type_name) { + static const char* ucum_numeric_types[] = { + "number", "integer", "float", "double", "decimal", + "int32", "uint32", "int64", "uint64", "int128", "uint128", + NULL + }; + + return is_string_in_list(type_name, ucum_numeric_types); +} + +static bool validate_relation_ref_object(validate_context_t* ctx, const cJSON* value, const char* keyword) { + bool valid = true; + size_t keyword_len = strlen(ctx->path); + push_path(ctx, keyword); + + if (!value || !cJSON_IsObject(value)) { + char msg[128]; + snprintf(msg, sizeof(msg), "'%s' must be an object with '$ref'.", keyword); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, msg); + pop_path(ctx, keyword_len); + return false; + } + + const cJSON* ref = cJSON_GetObjectItemCaseSensitive(value, "$ref"); + if (!ref || !cJSON_IsString(ref)) { + char msg[128]; + snprintf(msg, sizeof(msg), "'%s' must be an object with '$ref'.", keyword); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, msg); + pop_path(ctx, keyword_len); + return false; + } + + if (ref->valuestring && ref->valuestring[0] == '#') { + if (!resolve_ref(ctx, ref->valuestring)) { + size_t ref_len = strlen(ctx->path); + push_path(ctx, "$ref"); + char msg[256]; + snprintf(msg, sizeof(msg), "$ref '%s' not found", ref->valuestring); + add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); + pop_path(ctx, ref_len); + valid = false; + } + } + + pop_path(ctx, keyword_len); + return valid; +} + /* ============================================================================ * Type Validation * ============================================================================ */ @@ -717,6 +784,214 @@ static bool validate_constraints(validate_context_t* ctx, const cJSON* schema, c return valid; } +static bool validate_ucum_unit_keyword(validate_context_t* ctx, const cJSON* schema, const char* type_name) { + const cJSON* ucum_unit = cJSON_GetObjectItemCaseSensitive(schema, "ucumUnit"); + if (!ucum_unit) return true; + + bool valid = true; + size_t prev_len = strlen(ctx->path); + push_path(ctx, "ucumUnit"); + + if (!has_enabled_extension(ctx, "JSONStructureUnits")) { + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'ucumUnit' requires JSONStructureUnits extension."); + valid = false; + } + + if (!cJSON_IsString(ucum_unit)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, "'ucumUnit' must be a string."); + valid = false; + } + + if (!is_ucum_numeric_type(type_name)) { + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'ucumUnit' can only appear in numeric schemas."); + valid = false; + } + + pop_path(ctx, prev_len); + return valid; +} + +static bool validate_relations_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name) { + const cJSON* identity = cJSON_GetObjectItemCaseSensitive(schema, "identity"); + const cJSON* relations = cJSON_GetObjectItemCaseSensitive(schema, "relations"); + if (!identity && !relations) return true; + + bool valid = true; + bool supports_relations = type_name && + (strcmp(type_name, "object") == 0 || strcmp(type_name, "tuple") == 0); + + if (!has_enabled_extension(ctx, "JSONStructureRelations")) { + if (identity) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "identity"); + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'identity' requires JSONStructureRelations extension."); + pop_path(ctx, prev_len); + valid = false; + } + if (relations) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "relations"); + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'relations' requires JSONStructureRelations extension."); + pop_path(ctx, prev_len); + valid = false; + } + } + + if (identity) { + size_t identity_len = strlen(ctx->path); + push_path(ctx, "identity"); + + if (!supports_relations) { + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'identity' can only appear in object or tuple schemas."); + valid = false; + } + + if (!cJSON_IsArray(identity)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "'identity' must be an array of strings."); + valid = false; + } else { + const cJSON* properties = cJSON_GetObjectItemCaseSensitive(schema, "properties"); + cJSON* item = NULL; + int index = 0; + cJSON_ArrayForEach(item, identity) { + char index_segment[32]; + snprintf(index_segment, sizeof(index_segment), "[%d]", index); + size_t item_len = strlen(ctx->path); + push_path(ctx, index_segment); + + if (!cJSON_IsString(item)) { + char msg[128]; + snprintf(msg, sizeof(msg), "'identity[%d]' must be a string.", index); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, msg); + valid = false; + } else if (!properties || !cJSON_IsObject(properties) || + !cJSON_GetObjectItemCaseSensitive(properties, item->valuestring)) { + char msg[256]; + snprintf(msg, sizeof(msg), "'identity' references property '%s' that is not in 'properties'.", + item->valuestring); + add_error(ctx, JS_SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED, msg); + valid = false; + } + + pop_path(ctx, item_len); + index++; + } + } + + pop_path(ctx, identity_len); + } + + if (!relations) return valid; + + size_t relations_len = strlen(ctx->path); + push_path(ctx, "relations"); + + if (!supports_relations) { + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'relations' can only appear in object or tuple schemas."); + valid = false; + } + + if (!cJSON_IsObject(relations)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, "'relations' must be an object."); + pop_path(ctx, relations_len); + return false; + } + + cJSON* relation = NULL; + cJSON_ArrayForEach(relation, relations) { + size_t relation_len = strlen(ctx->path); + push_path(ctx, relation->string ? relation->string : ""); + + if (!cJSON_IsObject(relation)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "Relation declaration must be an object."); + valid = false; + pop_path(ctx, relation_len); + continue; + } + + const cJSON* targettype = cJSON_GetObjectItemCaseSensitive(relation, "targettype"); + if (!targettype) { + size_t target_len = strlen(ctx->path); + push_path(ctx, "targettype"); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "Relation declaration must have 'targettype'."); + pop_path(ctx, target_len); + valid = false; + } else if (!validate_relation_ref_object(ctx, targettype, "targettype")) { + valid = false; + } + + const cJSON* cardinality = cJSON_GetObjectItemCaseSensitive(relation, "cardinality"); + if (!cardinality) { + size_t card_len = strlen(ctx->path); + push_path(ctx, "cardinality"); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "Relation declaration must have 'cardinality'."); + pop_path(ctx, card_len); + valid = false; + } else { + size_t card_len = strlen(ctx->path); + push_path(ctx, "cardinality"); + if (!cJSON_IsString(cardinality) || + (strcmp(cardinality->valuestring, "single") != 0 && + strcmp(cardinality->valuestring, "multiple") != 0)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "'cardinality' must be 'single' or 'multiple'."); + valid = false; + } + pop_path(ctx, card_len); + } + + const cJSON* scope = cJSON_GetObjectItemCaseSensitive(relation, "scope"); + if (scope) { + size_t scope_len = strlen(ctx->path); + push_path(ctx, "scope"); + if (cJSON_IsString(scope)) { + /* valid */ + } else if (cJSON_IsArray(scope)) { + cJSON* scope_item = NULL; + int scope_index = 0; + cJSON_ArrayForEach(scope_item, scope) { + if (!cJSON_IsString(scope_item)) { + char index_segment[32]; + snprintf(index_segment, sizeof(index_segment), "[%d]", scope_index); + size_t item_len = strlen(ctx->path); + push_path(ctx, index_segment); + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "'scope' array items must be strings."); + pop_path(ctx, item_len); + valid = false; + } + scope_index++; + } + } else { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, + "'scope' must be a string or an array of strings."); + valid = false; + } + pop_path(ctx, scope_len); + } + + const cJSON* qualifiertype = cJSON_GetObjectItemCaseSensitive(relation, "qualifiertype"); + if (qualifiertype && !validate_relation_ref_object(ctx, qualifiertype, "qualifiertype")) { + valid = false; + } + + pop_path(ctx, relation_len); + } + + pop_path(ctx, relations_len); + return valid; +} + /* ============================================================================ * Schema Node Validation * ============================================================================ */ @@ -1229,6 +1504,13 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { if (!validate_choice_schema(ctx, schema)) valid = false; } } + + if (!validate_ucum_unit_keyword(ctx, schema, type_str)) { + valid = false; + } + if (!validate_relations_keywords(ctx, schema, type_str)) { + valid = false; + } } /* Validate enum if present */ diff --git a/c/tests/test_schema_validator.c b/c/tests/test_schema_validator.c index 7a54952..aa697f3 100644 --- a/c/tests/test_schema_validator.c +++ b/c/tests/test_schema_validator.c @@ -18,6 +18,19 @@ } \ } while(0) +static int result_has_message(const js_result_t* result, const char* needle) { + size_t i; + if (!result || !needle) return 0; + + for (i = 0; i < result->error_count; i++) { + if (result->errors[i].message && strstr(result->errors[i].message, needle)) { + return 1; + } + } + + return 0; +} + /* ============================================================================ * Valid Schema Tests * ============================================================================ */ @@ -333,27 +346,136 @@ TEST(is_valid_compound_type) { * ============================================================================ */ TEST(placeholder_ucum_unit_keyword_coverage) { - /* - * Pending dedicated schema-validation coverage for the ucumUnit keyword: - * - numeric type with ucumUnit string - * - numeric type with both unit and ucumUnit - * - extended numeric types (int32, float, double, decimal) - * - non-numeric types with ucumUnit rejected - * - non-string ucumUnit values rejected - */ + const char* valid_schema = "{" + "\"$id\": \"test\"," + "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," + "\"name\": \"Length\"," + "\"$uses\": [\"JSONStructureUnits\"]," + "\"type\": \"number\"," + "\"ucumUnit\": \"m\"" + "}"; + const char* invalid_type_schema = "{" + "\"$id\": \"test\"," + "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," + "\"name\": \"BadUcumType\"," + "\"type\": \"string\"," + "\"ucumUnit\": \"m\"" + "}"; + const char* invalid_value_schema = "{" + "\"$id\": \"test\"," + "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," + "\"name\": \"BadUcumValue\"," + "\"$uses\": [\"JSONStructureUnits\"]," + "\"type\": \"number\"," + "\"ucumUnit\": 5" + "}"; + + js_result_t result; + bool valid; + + js_result_init(&result); + valid = js_validate_schema(valid_schema, &result); + if (!valid || result.error_count != 0) { + js_result_cleanup(&result); + return 1; + } + js_result_cleanup(&result); + + js_result_init(&result); + valid = js_validate_schema(invalid_type_schema, &result); + if (valid || !result_has_message(&result, "JSONStructureUnits extension") || + !result_has_message(&result, "numeric schemas")) { + js_result_cleanup(&result); + return 1; + } + js_result_cleanup(&result); + + js_result_init(&result); + valid = js_validate_schema(invalid_value_schema, &result); + if (valid || !result_has_message(&result, "must be a string")) { + js_result_cleanup(&result); + return 1; + } + js_result_cleanup(&result); + return 0; } TEST(placeholder_relations_extension_coverage) { - /* - * Pending dedicated schema-validation coverage for the Relations extension: - * - valid identity arrays on object types - * - valid relation declarations, targettype refs, scope, and qualifiertype - * - invalid identity / relations on non-object types - * - invalid identity shapes and missing properties - * - invalid cardinality values - * - missing targettype / cardinality - */ + const char* valid_schema = "{" + "\"$id\": \"test\"," + "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," + "\"name\": \"Order\"," + "\"$uses\": [\"JSONStructureRelations\"]," + "\"type\": \"object\"," + "\"properties\": {" + "\"id\": {\"type\": \"string\"}," + "\"customerId\": {\"type\": \"string\"}" + "}," + "\"identity\": [\"id\"]," + "\"relations\": {" + "\"customer\": {" + "\"cardinality\": \"single\"," + "\"targettype\": {\"$ref\": \"#/definitions/Customer\"}," + "\"scope\": [\"tenant\", \"region\"]," + "\"qualifiertype\": {\"$ref\": \"#/definitions/RelationQualifier\"}" + "}" + "}," + "\"definitions\": {" + "\"Customer\": {" + "\"name\": \"Customer\"," + "\"type\": \"object\"," + "\"properties\": {\"id\": {\"type\": \"string\"}}" + "}," + "\"RelationQualifier\": {" + "\"name\": \"RelationQualifier\"," + "\"type\": \"string\"" + "}" + "}" + "}"; + const char* invalid_schema = "{" + "\"$id\": \"test\"," + "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," + "\"name\": \"BadRelations\"," + "\"type\": \"string\"," + "\"identity\": [\"id\"]," + "\"relations\": {" + "\"customer\": {" + "\"cardinality\": \"many\"," + "\"targettype\": {\"type\": \"object\"}," + "\"scope\": [\"tenant\", 3]," + "\"qualifiertype\": {\"type\": \"string\"}" + "}" + "}" + "}"; + + js_result_t result; + bool valid; + + js_result_init(&result); + valid = js_validate_schema(valid_schema, &result); + if (!valid || result.error_count != 0) { + js_result_cleanup(&result); + return 1; + } + js_result_cleanup(&result); + + js_result_init(&result); + valid = js_validate_schema(invalid_schema, &result); + if (valid || + !result_has_message(&result, "JSONStructureRelations extension") || + !result_has_message(&result, "'identity' can only appear in object or tuple schemas") || + !result_has_message(&result, "property 'id' that is not in 'properties'") || + !result_has_message(&result, "'relations' can only appear in object or tuple schemas") || + !result_has_message(&result, "'targettype' must be an object with '$ref'") || + !result_has_message(&result, "'cardinality' must be 'single' or 'multiple'") || + !result_has_message(&result, "'scope' array items must be strings") || + !result_has_message(&result, "'qualifiertype' must be an object with '$ref'")) { + js_result_cleanup(&result); + return 1; + } + js_result_cleanup(&result); + return 0; } From ee19e6be2c6bb69c21ec4ab251767099e621163e Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 14:32:40 +0200 Subject: [PATCH 13/20] fix: resolve PHP undefined variable warning and Perl ucumUnit type check PHP: Escape $ref in double-quoted string interpolation at line 633 to prevent 'Undefined variable \' warning (triggered by 4 tests). Perl: Add looks_like_number() check to catch numeric scalars passed as ucumUnit value (JSON number 42 was not caught by ref() check). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- perl/lib/JSON/Structure/SchemaValidator.pm | 4 ++-- php/src/JsonStructure/SchemaValidator.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 796bdec..1f22018 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -7,7 +7,7 @@ use v5.20; our $VERSION = '0.01'; use JSON::MaybeXS; -use Scalar::Util qw(blessed); +use Scalar::Util qw(blessed looks_like_number); use JSON::Structure::Types; use JSON::Structure::ErrorCodes qw(:all); use JSON::Structure::JsonSourceLocator; @@ -1384,7 +1384,7 @@ sub _validate_ucum_unit_keyword { ); } - if ( !defined $schema->{ucumUnit} || ref( $schema->{ucumUnit} ) ) { + if ( !defined $schema->{ucumUnit} || ref( $schema->{ucumUnit} ) || looks_like_number( $schema->{ucumUnit} ) ) { $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE, "'ucumUnit' must be a string.", diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index 37e6fc8..33110b1 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -630,7 +630,7 @@ private function checkRelationRefObject(mixed $value, string $keyword, string $r return; } - $this->checkJsonPointer($value['$ref'], $this->doc, "{$keywordPath}/$ref"); + $this->checkJsonPointer($value['$ref'], $this->doc, "{$keywordPath}/\$ref"); } private function checkExtendedValidationKeywords(array $obj, string $path): void From e97abd1230f3743cc397ccf589e1a107215a50df Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 15:10:18 +0200 Subject: [PATCH 14/20] feat: add validation extension gating and unit/currency/symbols enforcement All 11 SDKs now have full spec coverage for extension keyword gating: - C, Ruby: Added Validation extension gating (warn when validation keywords like pattern, minLength, etc. are used without $uses: [JSONStructureValidation]) - All 10 non-TS SDKs: Added unit/currency/symbols gating under JSONStructureUnits extension. The unit keyword now gets string + numeric type validation (same as ucumUnit). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/src/schema_validator.c | 118 ++++++++++++++++++ .../Validation/SchemaValidator.cs | 24 +++- go/error_codes.go | 2 + go/schema_validator.go | 37 +++--- .../json_structure/validation/ErrorCodes.java | 2 + .../validation/SchemaValidator.java | 44 ++++--- perl/lib/JSON/Structure/SchemaValidator.pm | 39 +++++- php/src/JsonStructure/SchemaValidator.php | 27 ++++ python/src/json_structure/schema_validator.py | 37 +++++- ruby/lib/jsonstructure/schema_validator.rb | 46 +++++++ rust/src/error_codes.rs | 66 +++++----- rust/src/schema_validator.rs | 66 +++++----- .../JSONStructure/SchemaValidator.swift | 28 +++++ 13 files changed, 431 insertions(+), 105 deletions(-) diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index ff5e013..c2f340c 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -12,6 +12,10 @@ #include #include +#ifndef JS_SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED +#define JS_SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES +#endif + /* ============================================================================ * Internal Constants * ============================================================================ */ @@ -75,9 +79,12 @@ static bool validate_map_values(validate_context_t* ctx, const cJSON* schema); static bool validate_choice_schema(validate_context_t* ctx, const cJSON* schema); static bool validate_constraints(validate_context_t* ctx, const cJSON* schema, const char* type_name); static bool validate_ucum_unit_keyword(validate_context_t* ctx, const cJSON* schema, const char* type_name); +static bool validate_units_extension_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name); static bool validate_relations_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name); static const cJSON* resolve_ref(validate_context_t* ctx, const char* ref); static bool process_imports(import_context_t* ctx, cJSON* obj, const char* path); +static bool is_validation_extension_keyword(const char* keyword); +static void collect_validation_keyword_warnings(validate_context_t* ctx, const cJSON* node); /* ============================================================================ * Helper Functions @@ -148,6 +155,56 @@ static bool is_ucum_numeric_type(const char* type_name) { return is_string_in_list(type_name, ucum_numeric_types); } +static bool is_validation_extension_keyword(const char* keyword) { + static const char* validation_extension_keywords[] = { + "pattern", "format", "minLength", "maxLength", "minimum", "maximum", + "exclusiveMinimum", "exclusiveMaximum", "multipleOf", + "minItems", "maxItems", "uniqueItems", "contains", "minContains", "maxContains", + "minProperties", "maxProperties", "propertyNames", "patternProperties", "dependentRequired", + "minEntries", "maxEntries", "patternKeys", "keyNames", + "contentEncoding", "contentMediaType", "has", "default", + NULL + }; + + return is_string_in_list(keyword, validation_extension_keywords); +} + +static void collect_validation_keyword_warnings(validate_context_t* ctx, const cJSON* node) { + if (!ctx || !node) return; + + if (cJSON_IsObject(node)) { + cJSON* child = NULL; + cJSON_ArrayForEach(child, node) { + size_t prev_len = strlen(ctx->path); + if (child->string) { + push_path(ctx, child->string); + + if (is_validation_extension_keyword(child->string)) { + char msg[256]; + snprintf(msg, sizeof(msg), + "Validation extension keyword '%s' is used but validation extensions are not enabled. Add '\"$uses\": [\"JSONStructureValidation\"]' to enable validation, or this keyword will be ignored.", + child->string); + add_warning(ctx, JS_SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, msg); + } + } + + collect_validation_keyword_warnings(ctx, child); + pop_path(ctx, prev_len); + } + } else if (cJSON_IsArray(node)) { + cJSON* child = NULL; + int index = 0; + cJSON_ArrayForEach(child, node) { + size_t prev_len = strlen(ctx->path); + char segment[32]; + snprintf(segment, sizeof(segment), "[%d]", index++); + push_path(ctx, segment); + collect_validation_keyword_warnings(ctx, child); + pop_path(ctx, prev_len); + } + } +} + static bool validate_relation_ref_object(validate_context_t* ctx, const cJSON* value, const char* keyword) { bool valid = true; size_t keyword_len = strlen(ctx->path); @@ -813,6 +870,60 @@ static bool validate_ucum_unit_keyword(validate_context_t* ctx, const cJSON* sch return valid; } +static bool validate_units_extension_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name) { + const cJSON* unit = cJSON_GetObjectItemCaseSensitive(schema, "unit"); + const cJSON* currency = cJSON_GetObjectItemCaseSensitive(schema, "currency"); + const cJSON* symbols = cJSON_GetObjectItemCaseSensitive(schema, "symbols"); + bool units_enabled = has_enabled_extension(ctx, "JSONStructureUnits"); + bool valid = true; + + if (unit) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "unit"); + + if (!units_enabled) { + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'unit' requires JSONStructureUnits extension."); + valid = false; + } + if (!cJSON_IsString(unit)) { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, "'unit' must be a string."); + valid = false; + } + if (!is_ucum_numeric_type(type_name)) { + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'unit' can only appear in numeric schemas."); + valid = false; + } + + pop_path(ctx, prev_len); + } + + if (currency) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "currency"); + if (!units_enabled) { + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'currency' requires JSONStructureUnits extension."); + valid = false; + } + pop_path(ctx, prev_len); + } + + if (symbols) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "symbols"); + if (!units_enabled) { + add_error(ctx, JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES, + "'symbols' requires JSONStructureUnits extension."); + valid = false; + } + pop_path(ctx, prev_len); + } + + return valid; +} + static bool validate_relations_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name) { const cJSON* identity = cJSON_GetObjectItemCaseSensitive(schema, "identity"); const cJSON* relations = cJSON_GetObjectItemCaseSensitive(schema, "relations"); @@ -1508,6 +1619,9 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { if (!validate_ucum_unit_keyword(ctx, schema, type_str)) { valid = false; } + if (!validate_units_extension_keywords(ctx, schema, type_str)) { + valid = false; + } if (!validate_relations_keywords(ctx, schema, type_str)) { valid = false; } @@ -1602,6 +1716,10 @@ static bool validate_root_schema(validate_context_t* ctx, const cJSON* schema) { ctx->definitions = cJSON_GetObjectItemCaseSensitive(schema, "$definitions"); } + if (!has_enabled_extension(ctx, "JSONStructureValidation")) { + collect_validation_keyword_warnings(ctx, schema); + } + /* Validate the schema tree */ if (!validate_schema_node(ctx, schema)) { valid = false; diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 8135153..5440f2d 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -83,7 +83,7 @@ public sealed class SchemaValidator // Alternate names "altnames", // Units - "unit", "ucumUnit", + "unit", "ucumUnit", "currency", "symbols", // Relations "identity", "relations" }; @@ -1138,12 +1138,28 @@ private bool IsNumericType(string? type) => type is private void ValidateUnitsAndRelationsKeywords(JsonObject schema, string? typeStr, string path, ValidationResult result) { + foreach (var keyword in new[] { "unit", "ucumUnit", "currency", "symbols" }) + { + RequireExtension(schema, keyword, "JSONStructureUnits", path, result); + } + + if (schema.TryGetPropertyValue("unit", out var unitKeywordValue)) + { + if (unitKeywordValue is not JsonValue unitValue || !unitValue.TryGetValue(out _)) + { + AddError(result, ErrorCodes.SchemaKeywordInvalidType, "unit must be a string", AppendPath(path, "unit")); + } + + if (typeStr is not null && !IsNumericType(typeStr)) + { + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'unit' constraint is only valid for numeric types, not '{typeStr}'", AppendPath(path, "unit")); + } + } + if (!schema.TryGetPropertyValue("ucumUnit", out var ucumUnitValue)) return; - RequireExtension(schema, "ucumUnit", "JSONStructureUnits", path, result); - - if (ucumUnitValue is not JsonValue unitValue || !unitValue.TryGetValue(out _)) + if (ucumUnitValue is not JsonValue unitValue2 || !unitValue2.TryGetValue(out _)) { AddError(result, ErrorCodes.SchemaKeywordInvalidType, "ucumUnit must be a string", AppendPath(path, "ucumUnit")); } diff --git a/go/error_codes.go b/go/error_codes.go index 19ea246..4816f16 100644 --- a/go/error_codes.go +++ b/go/error_codes.go @@ -74,6 +74,8 @@ const ( SchemaNameInvalid = "SCHEMA_NAME_INVALID" // SchemaConstraintInvalidForType indicates constraint is not valid for this type. SchemaConstraintInvalidForType = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE" + // SchemaConstraintTypeMismatch indicates a keyword is used with an incompatible schema type. + SchemaConstraintTypeMismatch = "SCHEMA_CONSTRAINT_TYPE_MISMATCH" // SchemaMinGreaterThanMax indicates minimum cannot be greater than maximum. SchemaMinGreaterThanMax = "SCHEMA_MIN_GREATER_THAN_MAX" // SchemaPropertiesNotObject indicates properties must be an object. diff --git a/go/schema_validator.go b/go/schema_validator.go index 728f419..ed1f3c8 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -154,23 +154,30 @@ func hasExtension(schema map[string]interface{}, extension string) bool { return false } -func (ctx *schemaValidationContext) validateUcumUnitKeyword(schema map[string]interface{}, path string) { - ucumUnit, ok := schema["ucumUnit"] - if !ok { - return - } +func (ctx *schemaValidationContext) validateUnitsKeywords(schema map[string]interface{}, path string) { + unitsEnabled := hasExtension(schema, "JSONStructureUnits") + for _, keyword := range []string{"unit", "ucumUnit", "currency", "symbols"} { + value, ok := schema[keyword] + if !ok { + continue + } - if !hasExtension(schema, "JSONStructureUnits") { - ctx.addError(path+"/ucumUnit", "ucumUnit requires JSONStructureUnits in $uses", SchemaExtensionKeywordNotEnabled) - } + if !unitsEnabled { + ctx.addError(path+"/"+keyword, fmt.Sprintf("%s requires JSONStructureUnits in $uses", keyword), SchemaExtensionKeywordNotEnabled) + } - if _, ok := ucumUnit.(string); !ok { - ctx.addError(path+"/ucumUnit", "ucumUnit must be a string", SchemaKeywordInvalidType) - } + if keyword != "unit" && keyword != "ucumUnit" { + continue + } - typeStr, ok := schema["type"].(string) - if !ok || !isNumericType(typeStr) { - ctx.addError(path+"/ucumUnit", "ucumUnit is only valid for numeric types", SchemaConstraintInvalidForType) + if _, ok := value.(string); !ok { + ctx.addError(path+"/"+keyword, fmt.Sprintf("%s must be a string", keyword), SchemaKeywordInvalidType) + } + + typeStr, ok := schema["type"].(string) + if !ok || !isNumericType(typeStr) { + ctx.addError(path+"/"+keyword, fmt.Sprintf("%s is only valid for numeric types", keyword), SchemaConstraintTypeMismatch) + } } } @@ -484,7 +491,7 @@ func (ctx *schemaValidationContext) validateTypeDefinition(schema map[string]int } typeVal, hasType := schema["type"] - ctx.validateUcumUnitKeyword(schema, path) + ctx.validateUnitsKeywords(schema, path) ctx.validateRelationsKeywords(schema, path) // Type is required unless it's a conditional-only schema diff --git a/java/src/main/java/org/json_structure/validation/ErrorCodes.java b/java/src/main/java/org/json_structure/validation/ErrorCodes.java index a0d873d..9355346 100644 --- a/java/src/main/java/org/json_structure/validation/ErrorCodes.java +++ b/java/src/main/java/org/json_structure/validation/ErrorCodes.java @@ -52,6 +52,8 @@ private ErrorCodes() { public static final String SCHEMA_NAME_INVALID = "SCHEMA_NAME_INVALID"; /** Constraint is not valid for this type. */ public static final String SCHEMA_CONSTRAINT_INVALID_FOR_TYPE = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE"; + /** Keyword is used with an incompatible schema type. */ + public static final String SCHEMA_CONSTRAINT_TYPE_MISMATCH = "SCHEMA_CONSTRAINT_TYPE_MISMATCH"; /** Minimum cannot be greater than maximum. */ public static final String SCHEMA_MIN_GREATER_THAN_MAX = "SCHEMA_MIN_GREATER_THAN_MAX"; /** Properties must be an object. */ diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index f857951..f712cde 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -100,7 +100,7 @@ public final class SchemaValidator { // Alternate names "altnames", // Units - "unit", "ucumUnit", + "unit", "ucumUnit", "currency", "symbols", // Relations "identity", "relations" ); @@ -592,7 +592,7 @@ private void validateSchemaCore(JsonNode node, ValidationResult result, String p validateAltnames(schema.get("altnames"), path, result); } - validateUcumUnit(schema, typeStr, path, result); + validateUnitsKeywords(schema, typeStr, path, result); validateRelationsKeywords(schema, typeStr, path, result); // A schema must have at least one schema-defining keyword (type, allOf, anyOf, oneOf, $extends) @@ -1383,25 +1383,33 @@ private boolean hasExtension(JsonNode schema, String extensionName) { return false; } - private void validateUcumUnit(ObjectNode schema, String typeStr, String path, ValidationResult result) { - if (!schema.has("ucumUnit")) { - return; - } + private void validateUnitsKeywords(ObjectNode schema, String typeStr, String path, ValidationResult result) { + boolean unitsEnabled = hasExtension(schema, "JSONStructureUnits"); - if (!hasExtension(schema, "JSONStructureUnits")) { - addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, - "ucumUnit requires 'JSONStructureUnits' in $uses", appendPath(path, "ucumUnit")); - } + for (String keyword : List.of("unit", "ucumUnit", "currency", "symbols")) { + if (!schema.has(keyword)) { + continue; + } - JsonNode ucumUnit = schema.get("ucumUnit"); - if (!ucumUnit.isTextual()) { - addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, - "ucumUnit must be a string", appendPath(path, "ucumUnit")); - } + if (!unitsEnabled) { + addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + keyword + " requires 'JSONStructureUnits' in $uses", appendPath(path, keyword)); + } - if (!NUMERIC_UNIT_TYPES.contains(typeStr)) { - addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, - "ucumUnit is only valid for numeric types", appendPath(path, "ucumUnit")); + if (!"unit".equals(keyword) && !"ucumUnit".equals(keyword)) { + continue; + } + + JsonNode value = schema.get(keyword); + if (!value.isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, + keyword + " must be a string", appendPath(path, keyword)); + } + + if (!NUMERIC_UNIT_TYPES.contains(typeStr)) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + keyword + " is only valid for numeric types", appendPath(path, keyword)); + } } } diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 1f22018..1c8dd5f 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -65,7 +65,7 @@ my %RESERVED_KEYWORDS = map { $_ => 1 } qw( description enum examples format items maxLength name precision properties required scale type values choices selector tuple - unit ucumUnit identity relations + unit ucumUnit currency symbols identity relations ); # Extended keywords for conditional composition @@ -1371,6 +1371,42 @@ sub _has_enabled_extension { return !!$self->{enabled_extensions}{$extension}; } +sub _validate_units_keywords { + my ( $self, $schema, $type, $path ) = @_; + + for my $keyword (qw(unit currency symbols)) { + next if !exists $schema->{$keyword}; + + if ( !$self->_has_enabled_extension('JSONStructureUnits') ) { + $self->_add_error( + SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + "'$keyword' requires JSONStructureUnits extension.", + "$path/$keyword" + ); + } + } + + return if !exists $schema->{unit}; + + if ( !defined $schema->{unit} || ref( $schema->{unit} ) || looks_like_number( $schema->{unit} ) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "'unit' must be a string.", + "$path/unit" + ); + } + + my %numeric_types = map { $_ => 1 } + qw(number integer float double decimal int32 uint32 int64 uint64 int128 uint128); + if ( !defined $type || ref($type) || !$numeric_types{$type} ) { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "'unit' can only appear in numeric schemas.", + "$path/unit" + ); + } +} + sub _validate_ucum_unit_keyword { my ( $self, $schema, $type, $path ) = @_; @@ -1627,6 +1663,7 @@ sub _validate_extended_keywords { $type //= ''; + $self->_validate_units_keywords( $schema, $type, $path ); $self->_validate_ucum_unit_keyword( $schema, $type, $path ); $self->_validate_relations_keywords( $schema, $type, $path ); diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index 33110b1..eb5cdb3 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -360,6 +360,7 @@ private function validateSchema( } $typeName = is_string($schemaObj['type'] ?? null) ? $schemaObj['type'] : null; + $this->checkUnitsKeywords($schemaObj, $typeName, $path); $this->checkUcumUnitKeyword($schemaObj, $typeName, $path); $this->checkRelationsKeywords($schemaObj, $typeName, $path); @@ -502,6 +503,32 @@ private function hasEnabledExtension(string $extension): bool return in_array($extension, $this->enabledExtensions, true); } + private function checkUnitsKeywords(array $obj, ?string $typeName, string $path): void + { + foreach (['unit', 'currency', 'symbols'] as $keyword) { + if (!array_key_exists($keyword, $obj)) { + continue; + } + + if (!$this->hasEnabledExtension('JSONStructureUnits')) { + $this->addError("'{$keyword}' requires JSONStructureUnits extension.", "{$path}/{$keyword}", ErrorCodes::SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED); + } + } + + if (!array_key_exists('unit', $obj)) { + return; + } + + if (!is_string($obj['unit'])) { + $this->addError("'unit' must be a string.", "{$path}/unit", ErrorCodes::SCHEMA_KEYWORD_INVALID_TYPE); + } + + $allowedTypes = ['number', 'integer', 'float', 'double', 'decimal', 'int32', 'uint32', 'int64', 'uint64', 'int128', 'uint128']; + if ($typeName === null || !in_array($typeName, $allowedTypes, true)) { + $this->addError("'unit' can only appear in numeric schemas.", "{$path}/unit", ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + } + private function checkUcumUnitKeyword(array $obj, ?string $typeName, string $path): void { if (!array_key_exists('ucumUnit', $obj)) { diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index b3c74f4..b3902e5 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -593,6 +593,7 @@ def _validate_schema(self, schema_obj, is_root=False, path="", name_in_namespace if "type" in schema_obj: self._check_extended_validation_keywords(schema_obj, path) self._check_ucum_unit_keyword(schema_obj, path) + self._check_units_keywords(schema_obj, path) self._check_relations_keywords(schema_obj, path) if "required" in schema_obj: @@ -753,11 +754,11 @@ def _check_ucum_unit_keyword(self, obj, path): return if "JSONStructureUnits" not in self.enabled_extensions: - self._err("'ucumUnit' requires JSONStructureUnits extension.", f"{path}/ucumUnit") + self._err("'ucumUnit' requires JSONStructureUnits extension.", f"{path}/ucumUnit", ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED) ucum_unit = obj["ucumUnit"] if not isinstance(ucum_unit, str): - self._err("'ucumUnit' must be a string.", f"{path}/ucumUnit") + self._err("'ucumUnit' must be a string.", f"{path}/ucumUnit", ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE) numeric_types = { "number", "integer", "float", "double", "decimal", @@ -765,7 +766,37 @@ def _check_ucum_unit_keyword(self, obj, path): } type_name = obj.get("type") if not isinstance(type_name, str) or type_name not in numeric_types: - self._err("'ucumUnit' can only appear in numeric schemas.", f"{path}/ucumUnit") + self._err("'ucumUnit' can only appear in numeric schemas.", f"{path}/ucumUnit", ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH) + + def _check_units_keywords(self, obj, path): + """ + Check the unit, currency, and symbols keywords from the JSONStructureUnits extension. + """ + numeric_types = { + "number", "integer", "float", "double", "decimal", + "int32", "uint32", "int64", "uint64", "int128", "uint128" + } + units_enabled = "JSONStructureUnits" in self.enabled_extensions + + for keyword in ("unit", "currency", "symbols"): + if keyword not in obj: + continue + if not units_enabled: + self._err( + f"'{keyword}' requires JSONStructureUnits extension.", + f"{path}/{keyword}", + ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, + ) + + if "unit" not in obj: + return + + if not isinstance(obj["unit"], str): + self._err("'unit' must be a string.", f"{path}/unit", ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE) + + type_name = obj.get("type") + if not isinstance(type_name, str) or type_name not in numeric_types: + self._err("'unit' can only appear in numeric schemas.", f"{path}/unit", ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH) def _check_relations_keywords(self, obj, path): """ diff --git a/ruby/lib/jsonstructure/schema_validator.rb b/ruby/lib/jsonstructure/schema_validator.rb index bf7d3f5..0d90c59 100644 --- a/ruby/lib/jsonstructure/schema_validator.rb +++ b/ruby/lib/jsonstructure/schema_validator.rb @@ -9,6 +9,14 @@ module JsonStructure class SchemaValidator UCUM_NUMERIC_TYPES = %w[number integer float double decimal int32 uint32 int64 uint64 int128 uint128].freeze RELATION_CONTAINER_TYPES = %w[object tuple].freeze + VALIDATION_KEYWORDS = %w[ + pattern format minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf + minItems maxItems uniqueItems contains minContains maxContains + minProperties maxProperties propertyNames patternProperties dependentRequired + minEntries maxEntries patternKeys keyNames + contentEncoding contentMediaType has default + ].freeze + UNITS_KEYWORDS = %w[unit currency symbols].freeze class << self # Validate a schema string @@ -81,7 +89,9 @@ def validate_extension_keywords(root_schema, node, path, errors) return unless node.is_a?(Hash) type = node['type'] + validate_validation_extension_gating(root_schema, node, path, errors) validate_ucum_unit_keyword(root_schema, node, type, path, errors) + validate_units_keywords(root_schema, node, type, path, errors) validate_relations_keywords(root_schema, node, type, path, errors) node.each do |key, value| @@ -97,6 +107,16 @@ def validate_extension_keywords(root_schema, node, path, errors) end end + def validate_validation_extension_gating(root_schema, node, path, errors) + return if extension_enabled?(root_schema, 'JSONStructureValidation') + + VALIDATION_KEYWORDS.each do |keyword| + next unless node.key?(keyword) + + add_manual_warning(errors, "'#{keyword}' requires JSONStructureValidation extension.", "#{path}/#{escape_json_pointer(keyword)}") + end + end + def validate_ucum_unit_keyword(root_schema, node, type, path, errors) return unless node.key?('ucumUnit') @@ -109,6 +129,22 @@ def validate_ucum_unit_keyword(root_schema, node, type, path, errors) add_manual_error(errors, "'ucumUnit' can only appear in numeric schemas.", "#{path}/ucumUnit") end + def validate_units_keywords(root_schema, node, type, path, errors) + UNITS_KEYWORDS.each do |keyword| + next unless node.key?(keyword) + + add_manual_error(errors, "'#{keyword}' requires JSONStructureUnits extension.", "#{path}/#{escape_json_pointer(keyword)}") unless extension_enabled?(root_schema, 'JSONStructureUnits') + end + + return unless node.key?('unit') + + add_manual_error(errors, "'unit' must be a string.", "#{path}/unit") unless node['unit'].is_a?(String) + + return if type.is_a?(String) && UCUM_NUMERIC_TYPES.include?(type) + + add_manual_error(errors, "'unit' can only appear in numeric schemas.", "#{path}/unit") + end + def validate_relations_keywords(root_schema, node, type, path, errors) has_identity = node.key?('identity') has_relations = node.key?('relations') @@ -247,6 +283,16 @@ def add_manual_error(errors, message, path) location: { line: 0, column: 0, offset: 0 } ) end + + def add_manual_warning(errors, message, path) + errors << ValidationError.new( + code: 0, + severity: FFI::JS_SEVERITY_WARNING, + path: path, + message: message, + location: { line: 0, column: 0, offset: 0 } + ) + end end end diff --git a/rust/src/error_codes.rs b/rust/src/error_codes.rs index a9aaab1..1346382 100644 --- a/rust/src/error_codes.rs +++ b/rust/src/error_codes.rs @@ -18,22 +18,22 @@ pub enum SchemaErrorCode { SchemaRootMissingName, SchemaRootMissingSchema, SchemaRootMissingType, - + // Type errors SchemaTypeInvalid, SchemaTypeNotString, - + // Reference errors SchemaRefNotFound, SchemaRefNotString, SchemaRefCircular, SchemaRefInvalid, - + // Definition errors SchemaDefinitionsMustBeObject, SchemaDefinitionMissingType, SchemaDefinitionInvalid, - + // Object property errors SchemaPropertiesMustBeObject, SchemaPropertyInvalid, @@ -41,40 +41,40 @@ pub enum SchemaErrorCode { SchemaRequiredItemMustBeString, SchemaRequiredPropertyNotDefined, SchemaAdditionalPropertiesInvalid, - + // Array/Set errors SchemaArrayMissingItems, SchemaItemsInvalid, - + // Map errors SchemaMapMissingValues, SchemaValuesInvalid, - + // Tuple errors SchemaTupleMissingDefinition, SchemaTupleMissingProperties, SchemaTupleInvalidFormat, SchemaTuplePropertyNotDefined, - + // Choice errors SchemaChoiceMissingChoices, SchemaChoicesNotObject, SchemaChoiceInvalid, SchemaSelectorNotString, - + // Enum/Const errors SchemaEnumNotArray, SchemaEnumEmpty, SchemaEnumDuplicates, SchemaConstInvalid, - + // Extension errors SchemaUsesNotArray, SchemaUsesInvalidExtension, SchemaOffersNotArray, SchemaOffersInvalidExtension, SchemaExtensionKeywordWithoutUses, - + // Constraint errors SchemaMinMaxInvalid, SchemaMinLengthInvalid, @@ -92,22 +92,22 @@ pub enum SchemaErrorCode { SchemaKeywordInvalidType, SchemaTypeArrayEmpty, SchemaTypeObjectMissingRef, - + // Import errors SchemaImportNotAllowed, SchemaImportFailed, SchemaImportCircular, - + // Extends errors SchemaExtendsNotString, SchemaExtendsEmpty, SchemaExtendsNotFound, SchemaExtendsCircular, - + // Altnames errors SchemaAltnamesNotObject, SchemaAltnamesValueNotString, - + // Composition errors SchemaAllOfNotArray, SchemaAnyOfNotArray, @@ -216,14 +216,14 @@ pub enum InstanceErrorCode { // Type mismatch errors InstanceTypeMismatch, InstanceTypeUnknown, - + // String errors InstanceStringExpected, InstanceStringTooShort, InstanceStringTooLong, InstanceStringPatternMismatch, InstanceStringFormatInvalid, - + // Number errors InstanceNumberExpected, InstanceNumberTooSmall, @@ -232,11 +232,11 @@ pub enum InstanceErrorCode { InstanceIntegerExpected, InstanceIntegerOutOfRange, InstanceDecimalExpected, - + // Boolean/Null errors InstanceBooleanExpected, InstanceNullExpected, - + // Object errors InstanceObjectExpected, InstanceRequiredMissing, @@ -247,7 +247,7 @@ pub enum InstanceErrorCode { InstanceDependentRequiredMissing, InstancePatternPropertyMismatch, InstancePropertyNameInvalid, - + // Array errors InstanceArrayExpected, InstanceArrayTooShort, @@ -257,35 +257,35 @@ pub enum InstanceErrorCode { InstanceArrayContainsTooFew, InstanceArrayContainsTooMany, InstanceArrayItemInvalid, - + // Tuple errors InstanceTupleExpected, InstanceTupleLengthMismatch, InstanceTupleElementInvalid, - + // Map errors InstanceMapExpected, InstanceMapValueInvalid, InstanceMapTooFewEntries, InstanceMapTooManyEntries, InstanceMapKeyPatternMismatch, - + // Set errors InstanceSetExpected, InstanceSetNotUnique, InstanceSetItemInvalid, - + // Choice errors InstanceChoiceNoMatch, InstanceChoiceMultipleMatches, InstanceChoiceUnknown, InstanceChoiceSelectorMissing, InstanceChoiceSelectorInvalid, - + // Enum/Const errors InstanceEnumMismatch, InstanceConstMismatch, - + // Date/Time errors InstanceDateExpected, InstanceDateInvalid, @@ -295,23 +295,23 @@ pub enum InstanceErrorCode { InstanceDateTimeInvalid, InstanceDurationExpected, InstanceDurationInvalid, - + // UUID errors InstanceUuidExpected, InstanceUuidInvalid, - + // URI errors InstanceUriExpected, InstanceUriInvalid, - + // Binary errors InstanceBinaryExpected, InstanceBinaryInvalid, - + // JSON Pointer errors InstanceJsonPointerExpected, InstanceJsonPointerInvalid, - + // Composition errors InstanceAllOfFailed, InstanceAnyOfFailed, @@ -320,10 +320,10 @@ pub enum InstanceErrorCode { InstanceNotFailed, InstanceIfThenFailed, InstanceIfElseFailed, - + // Reference errors InstanceRefNotFound, - + // Union errors InstanceUnionNoMatch, } diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index 2b9efed..aeb7cd8 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -271,7 +271,7 @@ impl SchemaValidator { } let type_name = obj.get("type").and_then(Value::as_str); - self.validate_ucum_unit_keyword(obj, locator, result, path, type_name, &enabled_extensions); + self.validate_units_keywords(obj, locator, result, path, type_name, &enabled_extensions); self.validate_relations_keywords( obj, root_schema, @@ -1874,7 +1874,7 @@ impl SchemaValidator { } } - fn validate_ucum_unit_keyword( + fn validate_units_keywords( &self, obj: &serde_json::Map, locator: &JsonSourceLocator, @@ -1883,38 +1883,42 @@ impl SchemaValidator { type_name: Option<&str>, enabled_extensions: &HashSet<&str>, ) { - let Some(ucum_unit) = obj.get("ucumUnit") else { - return; - }; + for keyword in ["unit", "ucumUnit", "currency", "symbols"] { + let Some(value) = obj.get(keyword) else { + continue; + }; - if !enabled_extensions.contains("JSONStructureUnits") { - let ucum_path = format!("{}/ucumUnit", path); - result.add_error(ValidationError::schema_error( - SchemaErrorCode::SchemaExtensionKeywordWithoutUses, - "ucumUnit requires 'JSONStructureUnits' in $uses", - &ucum_path, - locator.get_location(&ucum_path), - )); - } + let keyword_path = format!("{}/{}", path, keyword); + if !enabled_extensions.contains("JSONStructureUnits") { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaExtensionKeywordWithoutUses, + &format!("{} requires 'JSONStructureUnits' in $uses", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + } - if !ucum_unit.is_string() { - let ucum_path = format!("{}/ucumUnit", path); - result.add_error(ValidationError::schema_error( - SchemaErrorCode::SchemaKeywordInvalidType, - "ucumUnit must be a string", - &ucum_path, - locator.get_location(&ucum_path), - )); - } + if keyword != "unit" && keyword != "ucumUnit" { + continue; + } - if type_name.is_none_or(|name| !crate::types::is_numeric_type(name)) { - let ucum_path = format!("{}/ucumUnit", path); - result.add_error(ValidationError::schema_error( - SchemaErrorCode::SchemaConstraintTypeMismatch, - "ucumUnit is only valid for numeric types", - &ucum_path, - locator.get_location(&ucum_path), - )); + if !value.is_string() { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + &format!("{} must be a string", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + } + + if type_name.is_none_or(|name| !crate::types::is_numeric_type(name)) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + &format!("{} is only valid for numeric types", keyword), + &keyword_path, + locator.get_location(&keyword_path), + )); + } } } diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 2f109dd..10ec879 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -338,6 +338,7 @@ private final class ValidationEngine { } let typeName = schema["type"] as? String + validateUnitsKeywords(schema, typeName, path) validateUcumUnitKeyword(schema, typeName, path) validateRelationsKeywords(schema, typeName, path) } @@ -755,6 +756,33 @@ private final class ValidationEngine { } } + private func validateUnitsKeywords(_ schema: [String: Any], _ typeName: String?, _ path: String) { + for keyword in ["unit", "currency", "symbols"] { + guard schema[keyword] != nil else { + continue + } + + if !enabledExtensions.contains("JSONStructureUnits") { + addError("\(path)/\(keyword)", "'\(keyword)' requires JSONStructureUnits extension.", schemaExtensionKeywordNotEnabled) + } + } + + guard schema["unit"] != nil else { + return + } + + if !(schema["unit"] is String) { + addError("\(path)/unit", "'unit' must be a string.", schemaKeywordInvalidType) + } + + let allowedTypes: Set = ["number", "integer", "float", "double", "decimal", "int32", "uint32", "int64", "uint64", "int128", "uint128"] + if let typeName, allowedTypes.contains(typeName) { + return + } + + addError("\(path)/unit", "'unit' can only appear in numeric schemas.", schemaConstraintInvalidForType) + } + private func validateUcumUnitKeyword(_ schema: [String: Any], _ typeName: String?, _ path: String) { guard schema["ucumUnit"] != nil else { return From 88bb01a32fe0e7b9c7cb05cf1b7b5208371c95f6 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 15:17:26 +0200 Subject: [PATCH 15/20] fix: add $uses to Swift and Java unit keyword tests Tests using the unit keyword need JSONStructureUnits in $uses now that extension gating is enforced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../json_structure/validation/AdditionalValidationTests.java | 1 + swift/Tests/JSONStructureTests/AdditionalValidationTests.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java b/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java index 270db43..01f77cd 100644 --- a/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java +++ b/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java @@ -2112,6 +2112,7 @@ void shouldValidateSchemaWithUnit() { String schema = """ { "$id": "https://test.example.com/schema/unit", + "$uses": ["JSONStructureUnits"], "name": "UnitSchema", "type": "number", "unit": "meters" diff --git a/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift b/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift index b95b540..e105a5d 100644 --- a/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift +++ b/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift @@ -864,9 +864,10 @@ final class AdditionalValidationTests: XCTestCase { func testUnitKeywordInSchema() throws { let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) - // Schema with 'unit' annotation keyword + // Schema with 'unit' annotation keyword (requires JSONStructureUnits extension) let schema: [String: Any] = [ "$id": "urn:test", + "$uses": ["JSONStructureUnits"], "name": "Speed", "type": "object", "properties": [ From d32517784b204d98305f552731a909be3bb8b8f7 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 16:19:14 +0200 Subject: [PATCH 16/20] test: add adversarial and edge-case schema validation tests Adds 33 new tests (124 total, 9 skipped) covering: - $extends: valid, invalid target, circular, deep circular, non-string - Nested invalid schemas: inside properties, items, values, definitions - Multiple simultaneous errors in one schema - Extension keyword nesting (root $uses enabling nested keywords) - Complex $ref positions: unions, relations targettype/qualifiertype - $id edge cases: missing, empty, relative URI, valid URN - name edge cases: digit prefix, spaces, valid underscore - Enum edge cases: null values, single element, duplicates - Choice type: empty, invalid, valid multi-choice - Unknown keywords: silently ignored 9 tests are skipped with TODO comments documenting validator gaps: - $extends target type enforcement - Tuple entry type reference resolution - Root $uses inheritance into nested schemas - $id URI syntax validation - name identifier syntax enforcement - Enum value type checking against declared type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/tests/schema-validator.test.ts | 766 ++++++++++++++++++++++ 1 file changed, 766 insertions(+) diff --git a/typescript/tests/schema-validator.test.ts b/typescript/tests/schema-validator.test.ts index 2fc810a..a8308eb 100644 --- a/typescript/tests/schema-validator.test.ts +++ b/typescript/tests/schema-validator.test.ts @@ -1148,4 +1148,770 @@ describe('SchemaValidator', () => { expect(result.isValid).toBe(false); }); }); + + describe('adversarial and edge cases', () => { + const validate = (schema: any) => new SchemaValidator().validate(schema); + + describe('$extends validation', () => { + it('should accept $extends referencing an existing definition', () => { + const schema = { + $id: 'urn:example:extends-valid', + name: 'Employee', + type: 'object', + definitions: { + Person: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + $extends: '#/definitions/Person', + properties: { + department: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject $extends referencing a non-existent definition', () => { + const schema = { + $id: 'urn:example:extends-missing', + name: 'Employee', + type: 'object', + $extends: '#/definitions/MissingBase', + properties: { + department: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('$extends reference') && e.message.includes('not found'))).toBe(true); + }); + + it.skip('should reject $extends referencing a non-object definition', () => { + // TODO: validator does not yet enforce object-only $extends targets. + const schema = { + $id: 'urn:example:extends-primitive', + name: 'Employee', + type: 'object', + definitions: { + StringAlias: { + type: 'string', + }, + }, + $extends: '#/definitions/StringAlias', + properties: { + department: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should reject directly circular $extends chains', () => { + const schema = { + $id: 'urn:example:extends-direct-cycle', + name: 'Root', + type: 'object', + definitions: { + A: { + type: 'object', + $extends: '#/definitions/B', + properties: { + a: { type: 'string' }, + }, + }, + B: { + type: 'object', + $extends: '#/definitions/A', + properties: { + b: { type: 'string' }, + }, + }, + }, + properties: { + value: { type: { $ref: '#/definitions/A' } }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('Circular $extends reference'))).toBe(true); + }); + + it('should reject deeply circular $extends chains', () => { + const schema = { + $id: 'urn:example:extends-deep-cycle', + name: 'Root', + type: 'object', + definitions: { + A: { + type: 'object', + $extends: '#/definitions/B', + properties: { + a: { type: 'string' }, + }, + }, + B: { + type: 'object', + $extends: '#/definitions/C', + properties: { + b: { type: 'string' }, + }, + }, + C: { + type: 'object', + $extends: '#/definitions/A', + properties: { + c: { type: 'string' }, + }, + }, + }, + properties: { + value: { type: { $ref: '#/definitions/A' } }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('Circular $extends reference'))).toBe(true); + }); + + it('should reject non-string $extends values', () => { + const schema = { + $id: 'urn:example:extends-invalid-type', + name: 'Employee', + type: 'object', + $extends: 42, + properties: { + department: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('$extends must be a string or array of strings'))).toBe(true); + }); + + it('should reject $extends arrays containing non-string entries', () => { + const schema = { + $id: 'urn:example:extends-invalid-array-item', + name: 'Employee', + type: 'object', + $extends: ['#/definitions/Person', 42], + definitions: { + Person: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + properties: { + department: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('$extends array items must be strings'))).toBe(true); + }); + }); + + describe('nested invalid schemas', () => { + it('should reject an invalid type nested inside properties', () => { + const schema = { + $id: 'urn:example:nested-invalid-properties', + name: 'NestedInvalidProperties', + type: 'object', + properties: { + bad: { type: true }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/properties/bad/type' && e.message.includes('type must be a string, array, or object with $ref'))).toBe(true); + }); + + it('should reject an invalid type nested inside items', () => { + const schema = { + $id: 'urn:example:nested-invalid-items', + name: 'NestedInvalidItems', + type: 'array', + items: { type: true }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/items/type' && e.message.includes('type must be a string, array, or object with $ref'))).toBe(true); + }); + + it('should reject an invalid type nested inside values', () => { + const schema = { + $id: 'urn:example:nested-invalid-values', + name: 'NestedInvalidValues', + type: 'map', + values: { type: true }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/values/type' && e.message.includes('type must be a string, array, or object with $ref'))).toBe(true); + }); + + it('should reject an invalid type nested inside definitions', () => { + const schema = { + $id: 'urn:example:nested-invalid-definitions', + name: 'NestedInvalidDefinitions', + type: 'object', + definitions: { + Broken: { type: true }, + }, + properties: { + value: { type: 'string' }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/definitions/Broken/type' && e.message.includes('type must be a string, array, or object with $ref'))).toBe(true); + }); + + it.skip('should reject tuple entries that reference a type that does not exist', () => { + // TODO: validator does not yet model tuple entries as type references. + const schema = { + $id: 'urn:example:tuple-missing-ref', + name: 'TupleMissingRef', + type: 'tuple', + tuple: [{ $ref: '#/definitions/MissingType' }], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should report multiple independent nested errors', () => { + const schema = { + $id: 'urn:example:multiple-nested-errors', + name: 'MultipleNestedErrors', + type: 'object', + definitions: { + BrokenDefinition: { type: true }, + }, + properties: { + childList: { + type: 'array', + items: { type: false }, + }, + metadata: { + type: 'map', + values: { type: 123 }, + }, + alias: { + type: { $ref: '#/definitions/DoesNotExist' }, + }, + }, + }; + + const result = validate(schema); + const messages = result.errors.map(error => error.message).join('\n'); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(4); + expect(messages).toContain('type must be a string, array, or object with $ref'); + expect(messages).toContain("$ref '#/definitions/DoesNotExist' not found"); + }); + }); + + describe('extension keywords nested under root $uses', () => { + it.skip('should accept nested ucumUnit when the root enables JSONStructureUnits', () => { + // TODO: validator does not yet inherit root-level $uses into nested schemas. + const schema = { + $id: 'urn:example:nested-ucum-with-root-uses', + name: 'MeasurementEnvelope', + $uses: ['JSONStructureUnits'], + type: 'object', + properties: { + measurement: { + type: 'object', + properties: { + value: { + type: 'number', + ucumUnit: 'm', + }, + }, + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + }); + + it.skip('should accept nested relations when the root enables JSONStructureRelations', () => { + // TODO: validator does not yet inherit root-level $uses into nested schemas. + const schema = { + $id: 'urn:example:nested-relations-with-root-uses', + name: 'OrderEnvelope', + $uses: ['JSONStructureRelations'], + type: 'object', + definitions: { + Customer: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + properties: { + order: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + relations: { + customer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + }, + }, + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + }); + + it('should reject nested extension keywords when $uses is not enabled', () => { + const schema = { + $id: 'urn:example:nested-extension-without-uses', + name: 'MeasurementEnvelope', + type: 'object', + properties: { + measurement: { + type: 'number', + ucumUnit: 'm', + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/properties/measurement/ucumUnit' && e.message.includes("requires 'JSONStructureUnits' in $uses"))).toBe(true); + }); + }); + + describe('adversarial $ref in complex positions', () => { + it('should reject union members whose $ref points to a missing definition', () => { + const schema = { + $id: 'urn:example:union-ref-missing', + name: 'UnionRefMissing', + type: 'object', + properties: { + value: { + type: ['string', { $ref: '#/definitions/MissingType' }], + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/properties/value/type[1]' && e.message.includes('not found'))).toBe(true); + }); + + it('should reject relation targettype refs that point to missing definitions', () => { + const schema = { + $id: 'urn:example:relation-targettype-missing', + name: 'Order', + $uses: ['JSONStructureRelations'], + type: 'object', + properties: { + id: { type: 'string' }, + }, + relations: { + customer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/relations/customer/targettype/$ref' && e.message.includes('not found'))).toBe(true); + }); + + it('should reject relation qualifiertype refs that point to missing definitions', () => { + const schema = { + $id: 'urn:example:relation-qualifier-missing', + name: 'Order', + $uses: ['JSONStructureRelations'], + type: 'object', + properties: { + id: { type: 'string' }, + }, + definitions: { + Customer: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + relations: { + customer: { + cardinality: 'single', + targettype: { $ref: '#/definitions/Customer' }, + qualifiertype: { $ref: '#/definitions/MissingQualifier' }, + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/relations/customer/qualifiertype/$ref' && e.message.includes('not found'))).toBe(true); + }); + + it('should accept self-referencing property refs when the target definition exists', () => { + const schema = { + $id: 'urn:example:self-referencing-root', + name: 'Tree', + type: 'object', + definitions: { + Root: { + type: 'object', + properties: { + child: { type: { $ref: '#/definitions/Root' } }, + }, + }, + }, + properties: { + root: { type: { $ref: '#/definitions/Root' } }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('$id and name validation edge cases', () => { + it('should reject missing $id', () => { + const schema = { + name: 'MissingId', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes("Missing required '$id' keyword at root"))).toBe(true); + }); + + it.skip('should reject empty $id', () => { + // TODO: validator does not yet enforce non-empty $id values. + const schema = { + $id: '', + name: 'EmptyId', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it.skip('should reject relative $id values without a scheme', () => { + // TODO: validator does not yet validate $id URI syntax. + const schema = { + $id: 'relative/path', + name: 'RelativeId', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should accept a valid URN $id', () => { + const schema = { + $id: 'urn:example:test', + name: 'ValidUrn', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject missing name', () => { + const schema = { + $id: 'urn:example:missing-name', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes("must have a 'name' property"))).toBe(true); + }); + + it.skip('should reject names starting with a digit', () => { + // TODO: validator does not yet enforce identifier syntax for name. + const schema = { + $id: 'urn:example:digit-name', + name: '1InvalidName', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it.skip('should reject names containing spaces', () => { + // TODO: validator does not yet enforce identifier syntax for name. + const schema = { + $id: 'urn:example:space-name', + name: 'Invalid Name', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should accept names containing underscores and dollar signs', () => { + const schema = { + $id: 'urn:example:underscore-dollar-name', + name: 'Valid_Name$Type', + type: 'string', + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('enum edge cases', () => { + it('should accept enum values containing null for null types', () => { + const schema = { + $id: 'urn:example:enum-null', + name: 'NullableOnly', + type: 'null', + enum: [null], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it.skip('should reject mixed enum types when the schema type is string', () => { + // TODO: validator does not yet enforce enum element types against the declared type. + const schema = { + $id: 'urn:example:enum-mixed-string', + name: 'MixedStringEnum', + type: 'string', + enum: ['a', 1, true], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + }); + + it('should accept boolean enum values for boolean types', () => { + const schema = { + $id: 'urn:example:enum-boolean', + name: 'BooleanEnum', + type: 'boolean', + enum: [true, false], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept single-element enums', () => { + const schema = { + $id: 'urn:example:enum-single', + name: 'SingleEnum', + type: 'string', + enum: ['only'], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject duplicate enum values', () => { + const schema = { + $id: 'urn:example:enum-duplicate', + name: 'DuplicateEnum', + type: 'string', + enum: ['a', 'a'], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('enum values must be unique'))).toBe(true); + }); + }); + + describe('choice type edge cases', () => { + it('should reject empty choices arrays', () => { + const schema = { + $id: 'urn:example:choice-empty-array', + name: 'EmptyChoiceArray', + type: 'choice', + choices: [], + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.message.includes('choices must be an object'))).toBe(true); + }); + + it('should reject choice entries that are not objects or refs', () => { + const schema = { + $id: 'urn:example:choice-invalid-entry', + name: 'InvalidChoiceEntry', + type: 'choice', + choices: { + text: 'string', + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.path === '#/choices/text' && e.message.includes('Choice schema must be an object'))).toBe(true); + }); + + it('should accept choice types with multiple options', () => { + const schema = { + $id: 'urn:example:choice-multiple-options', + name: 'ShapeChoice', + type: 'choice', + choices: { + circle: { + type: 'object', + properties: { + radius: { type: 'double' }, + }, + }, + square: { + type: 'object', + properties: { + side: { type: 'double' }, + }, + }, + label: { + type: 'string', + }, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('unknown and extra keywords', () => { + it('should ignore vendor extension keywords', () => { + const schema = { + $id: 'urn:example:unknown-keyword', + name: 'UnknownKeyword', + type: 'string', + 'x-vendor-extension': true, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept deprecated as an annotation', () => { + const schema = { + $id: 'urn:example:deprecated-annotation', + name: 'DeprecatedAnnotation', + type: 'string', + deprecated: true, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should remain valid when multiple unknown keys are present', () => { + const schema = { + $id: 'urn:example:multiple-unknown-keys', + name: 'MultipleUnknownKeys', + type: 'object', + properties: { + value: { + type: 'string', + 'x-extra-field': 'ignored', + }, + }, + deprecated: false, + 'x-another-key': { + nested: true, + }, + }; + + const result = validate(schema); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + }); }); From bcf5fc7f45acf90acc1e2c30c2dfd8f8d8510c4c Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 16:42:53 +0200 Subject: [PATCH 17/20] feat: close all validator gaps and fix inheritance Implement remaining validation features across all 11 SDKs: - target must resolve to object/tuple type - Tuple entries validated for existence - Empty rejected - must have URI scheme - name must be valid identifier syntax - Enum values checked against declared type - Fix inheritance: nested schemas inherit root (Go, Java, TypeScript) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/include/json_structure/error_codes.h | 3 + c/src/error_codes.c | 3 + c/src/schema_validator.c | 261 ++++++++++++++--- c/tests/test_conformance.c | 90 +++--- c/tests/test_schema_validator.c | 162 +++++++++-- .../JsonStructure/Validation/ErrorCodes.cs | 6 + .../Validation/SchemaValidator.cs | 106 ++++++- go/error_codes.go | 2 + go/schema_validator.go | 95 +++++- go/validators_edge_cases_test.go | 106 ++++++- .../json_structure/validation/ErrorCodes.java | 2 + .../validation/SchemaValidator.java | 134 +++++++-- .../validation/SchemaValidatorTests.java | 124 ++++++++ perl/lib/JSON/Structure/SchemaValidator.pm | 86 +++++- php/src/JsonStructure/SchemaValidator.php | 46 ++- python/src/json_structure/schema_validator.py | 67 ++++- python/tests/test_schema_validator.py | 76 ++++- ruby/lib/jsonstructure/schema_validator.rb | 114 +++++++- ruby/spec/schema_validator_spec.rb | 126 ++++++++ rust/src/error_codes.rs | 6 + rust/src/schema_validator.rs | 271 ++++++++++++++++-- swift/Sources/JSONStructure/ErrorCodes.swift | 4 + .../JSONStructure/SchemaValidator.swift | 92 +++++- typescript/src/schema-validator.ts | 60 +++- typescript/tests/schema-validator.test.ts | 28 +- 25 files changed, 1839 insertions(+), 231 deletions(-) diff --git a/c/include/json_structure/error_codes.h b/c/include/json_structure/error_codes.h index 2c77744..4c34523 100644 --- a/c/include/json_structure/error_codes.h +++ b/c/include/json_structure/error_codes.h @@ -30,6 +30,8 @@ typedef enum { JS_SCHEMA_ROOT_MISSING_NAME, JS_SCHEMA_ROOT_MISSING_SCHEMA, JS_SCHEMA_ROOT_MISSING_TYPE, + JS_SCHEMA_KEYWORD_EMPTY, + JS_SCHEMA_NAME_INVALID, /* Type errors */ JS_SCHEMA_TYPE_INVALID, @@ -104,6 +106,7 @@ typedef enum { JS_SCHEMA_MINITEMS_NEGATIVE, JS_SCHEMA_MULTIPLEOF_INVALID, JS_SCHEMA_KEYWORD_INVALID_TYPE, + JS_SCHEMA_CONSTRAINT_VALUE_INVALID, /* Import errors */ JS_SCHEMA_IMPORT_NOT_ALLOWED, diff --git a/c/src/error_codes.c b/c/src/error_codes.c index cd303e3..131f45a 100644 --- a/c/src/error_codes.c +++ b/c/src/error_codes.c @@ -18,6 +18,8 @@ static const char* g_schema_error_messages[] = { [JS_SCHEMA_ROOT_MISSING_NAME] = "Root schema missing 'name' property", [JS_SCHEMA_ROOT_MISSING_SCHEMA] = "Root schema missing '$schema' property", [JS_SCHEMA_ROOT_MISSING_TYPE] = "Root schema missing 'type' property", + [JS_SCHEMA_KEYWORD_EMPTY] = "Keyword value cannot be empty", + [JS_SCHEMA_NAME_INVALID] = "Name must be a valid identifier", [JS_SCHEMA_TYPE_INVALID] = "Invalid type value", [JS_SCHEMA_TYPE_NOT_STRING] = "Type must be a string", @@ -81,6 +83,7 @@ static const char* g_schema_error_messages[] = { [JS_SCHEMA_MINITEMS_NEGATIVE] = "minItems cannot be negative", [JS_SCHEMA_MULTIPLEOF_INVALID] = "multipleOf must be positive", [JS_SCHEMA_KEYWORD_INVALID_TYPE] = "Keyword has invalid type", + [JS_SCHEMA_CONSTRAINT_VALUE_INVALID] = "Constraint value is invalid", [JS_SCHEMA_IMPORT_NOT_ALLOWED] = "$import not allowed", [JS_SCHEMA_IMPORT_FAILED] = "Failed to import schema", diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index c2f340c..2b61837 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -11,6 +11,7 @@ #include #include #include +#include #ifndef JS_SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED #define JS_SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED JS_SCHEMA_EXTENSION_KEYWORD_WITHOUT_USES @@ -81,6 +82,7 @@ static bool validate_constraints(validate_context_t* ctx, const cJSON* schema, c static bool validate_ucum_unit_keyword(validate_context_t* ctx, const cJSON* schema, const char* type_name); static bool validate_units_extension_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name); static bool validate_relations_keywords(validate_context_t* ctx, const cJSON* schema, const char* type_name); +static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* extends_node); static const cJSON* resolve_ref(validate_context_t* ctx, const char* ref); static bool process_imports(import_context_t* ctx, cJSON* obj, const char* path); static bool is_validation_extension_keyword(const char* keyword); @@ -129,6 +131,89 @@ static bool is_string_in_list(const char* str, const char** list) { return false; } +static bool is_blank_string(const char* str) { + if (!str) return true; + + while (*str) { + if (!isspace((unsigned char)*str)) { + return false; + } + str++; + } + + return true; +} + +static bool has_uri_scheme(const char* str) { + if (!str || !isalpha((unsigned char)str[0])) { + return false; + } + + for (size_t i = 1; str[i] != '\0'; ++i) { + unsigned char ch = (unsigned char)str[i]; + if (ch == ':') { + return true; + } + if (!isalnum(ch) && ch != '+' && ch != '-' && ch != '.') { + return false; + } + } + + return false; +} + +static bool is_valid_identifier(const char* str) { + if (!str || str[0] == '\0') { + return false; + } + + unsigned char first = (unsigned char)str[0]; + if (!isalpha(first) && first != '_' && first != '$') { + return false; + } + + for (size_t i = 1; str[i] != '\0'; ++i) { + unsigned char ch = (unsigned char)str[i]; + if (!isalnum(ch) && ch != '_' && ch != '$') { + return false; + } + } + + return true; +} + +static bool is_numeric_enum_type(const char* type_name) { + static const char* numeric_types[] = { + "integer", "int8", "int16", "int32", "int64", + "uint8", "uint16", "uint32", "uint64", + "float", "double", "decimal", + NULL + }; + + return is_string_in_list(type_name, numeric_types); +} + +static bool is_enum_value_valid_for_type(const cJSON* value, const char* type_name) { + if (!type_name || is_string_in_list(type_name, g_compound_types)) { + return true; + } + + if (strcmp(type_name, "string") == 0) { + return cJSON_IsString(value); + } + if (is_numeric_enum_type(type_name)) { + return cJSON_IsNumber(value); + } + if (strcmp(type_name, "boolean") == 0) { + return cJSON_IsBool(value); + } + if (strcmp(type_name, "null") == 0) { + return cJSON_IsNull(value); + } + + return true; +} + static bool has_enabled_extension(validate_context_t* ctx, const char* extension) { if (!ctx || !ctx->root_schema || !extension) return false; @@ -1303,11 +1388,14 @@ static bool validate_tuple_schema(validate_context_t* ctx, const cJSON* schema) /* If both are present and valid, validate that tuple references properties */ if (tuple_arr && cJSON_IsArray(tuple_arr) && properties && cJSON_IsObject(properties)) { cJSON* tuple_item; + int tuple_index = 0; cJSON_ArrayForEach(tuple_item, tuple_arr) { - if (!cJSON_IsString(tuple_item)) { - add_error(ctx, JS_SCHEMA_TUPLE_INVALID_FORMAT, "tuple array items must be strings"); - valid = false; - } else { + size_t item_prev_len = strlen(ctx->path); + char index_segment[32]; + snprintf(index_segment, sizeof(index_segment), "[%d]", tuple_index++); + push_path(ctx, index_segment); + + if (cJSON_IsString(tuple_item)) { if (!cJSON_GetObjectItemCaseSensitive(properties, tuple_item->valuestring)) { char msg[256]; snprintf(msg, sizeof(msg), "tuple references undefined property '%s'", @@ -1315,7 +1403,23 @@ static bool validate_tuple_schema(validate_context_t* ctx, const cJSON* schema) add_error(ctx, JS_SCHEMA_TUPLE_PROPERTY_NOT_DEFINED, msg); valid = false; } + } else if (cJSON_IsObject(tuple_item)) { + const cJSON* ref = cJSON_GetObjectItemCaseSensitive(tuple_item, "$ref"); + if (!ref || !cJSON_IsString(ref)) { + add_error(ctx, JS_SCHEMA_TUPLE_INVALID_FORMAT, "tuple $ref entries must contain a string $ref"); + valid = false; + } else if (!resolve_ref(ctx, ref->valuestring)) { + char msg[256]; + snprintf(msg, sizeof(msg), "$ref '%s' not found", ref->valuestring); + add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); + valid = false; + } + } else { + add_error(ctx, JS_SCHEMA_TUPLE_INVALID_FORMAT, "tuple array items must be strings or $ref objects"); + valid = false; } + + pop_path(ctx, item_prev_len); } /* Validate the properties themselves */ @@ -1380,7 +1484,7 @@ static bool validate_choice_schema(validate_context_t* ctx, const cJSON* schema) return valid; } -static bool validate_enum(validate_context_t* ctx, const cJSON* enum_node) { +static bool validate_enum(validate_context_t* ctx, const cJSON* enum_node, const char* type_name) { if (!cJSON_IsArray(enum_node)) { add_error(ctx, JS_SCHEMA_ENUM_NOT_ARRAY, "enum must be an array"); return false; @@ -1392,6 +1496,8 @@ static bool validate_enum(validate_context_t* ctx, const cJSON* enum_node) { return false; } + bool valid = true; + /* Check for duplicates */ for (int i = 0; i < size; i++) { const cJSON* a = cJSON_GetArrayItem(enum_node, i); @@ -1403,8 +1509,75 @@ static bool validate_enum(validate_context_t* ctx, const cJSON* enum_node) { } } } + + if (!type_name || js_schema_is_valid_compound_type(type_name)) { + return true; + } + + for (int i = 0; i < size; i++) { + const cJSON* value = cJSON_GetArrayItem(enum_node, i); + if (!is_enum_value_valid_for_type(value, type_name)) { + size_t prev_len = strlen(ctx->path); + char index_segment[32]; + snprintf(index_segment, sizeof(index_segment), "[%d]", i); + push_path(ctx, index_segment); + + char msg[256]; + snprintf(msg, sizeof(msg), "enum value is not valid for type '%s'", type_name); + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, msg); + pop_path(ctx, prev_len); + valid = false; + } + } - return true; + return valid; +} + +static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* extends_node) { + bool valid = true; + + if (cJSON_IsString(extends_node)) { + if (!extends_node->valuestring || extends_node->valuestring[0] != '#') { + return true; + } + + const cJSON* target = resolve_ref(ctx, extends_node->valuestring); + if (!target) { + char msg[256]; + snprintf(msg, sizeof(msg), "$extends reference '%s' not found", extends_node->valuestring); + add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); + return false; + } + + const cJSON* type = cJSON_GetObjectItemCaseSensitive(target, "type"); + if (!cJSON_IsString(type) || + (strcmp(type->valuestring, "object") != 0 && strcmp(type->valuestring, "tuple") != 0)) { + char msg[256]; + snprintf(msg, sizeof(msg), "$extends target '%s' must resolve to an object or tuple type", extends_node->valuestring); + add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, msg); + valid = false; + } + } else if (cJSON_IsArray(extends_node)) { + int index = 0; + cJSON* item = NULL; + cJSON_ArrayForEach(item, extends_node) { + size_t prev_len = strlen(ctx->path); + char index_segment[32]; + snprintf(index_segment, sizeof(index_segment), "[%d]", index++); + push_path(ctx, index_segment); + + if (!validate_extends_keyword(ctx, item)) { + valid = false; + } + + pop_path(ctx, prev_len); + } + } else { + add_error(ctx, JS_SCHEMA_KEYWORD_INVALID_TYPE, "$extends must be a string or array of strings"); + valid = false; + } + + return valid; } static bool validate_composition(validate_context_t* ctx, const cJSON* schema) { @@ -1550,38 +1723,11 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { if (!cJSON_IsString(ref)) { add_error(ctx, JS_SCHEMA_REF_NOT_STRING, "$ref must be a string"); valid = false; - } else { - /* Validate that the reference target exists */ - const char* ref_str = ref->valuestring; - if (ref_str[0] == '#') { - /* Internal reference - verify it exists in definitions */ - const char* def_name = NULL; - if (strncmp(ref_str, "#/definitions/", 14) == 0) { - def_name = ref_str + 14; - } else if (strncmp(ref_str, "#/$defs/", 8) == 0) { - def_name = ref_str + 8; - } else if (strncmp(ref_str, "#/$definitions/", 15) == 0) { - def_name = ref_str + 15; - } - - if (def_name) { - if (ctx->definitions) { - const cJSON* target = cJSON_GetObjectItemCaseSensitive(ctx->definitions, def_name); - if (!target) { - char msg[256]; - snprintf(msg, sizeof(msg), "Reference target '%s' not found in definitions", def_name); - add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); - valid = false; - } - } else { - char msg[256]; - snprintf(msg, sizeof(msg), "Reference '%s' used but no definitions defined", ref_str); - add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); - valid = false; - } - } - /* Note: Circular reference detection happens during instance validation */ - } + } else if (ref->valuestring[0] == '#' && !resolve_ref(ctx, ref->valuestring)) { + char msg[256]; + snprintf(msg, sizeof(msg), "$ref '%s' not found", ref->valuestring); + add_error(ctx, JS_SCHEMA_REF_NOT_FOUND, msg); + valid = false; } ctx->depth--; return valid; @@ -1589,13 +1735,12 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { /* Get type */ const cJSON* type_node = cJSON_GetObjectItemCaseSensitive(schema, "type"); + const char* type_str = cJSON_IsString(type_node) ? type_node->valuestring : NULL; if (type_node) { if (!validate_type_value(ctx, type_node)) { valid = false; } - const char* type_str = cJSON_IsString(type_node) ? type_node->valuestring : NULL; - if (type_str) { /* Validate type-specific constraints */ if (!validate_constraints(ctx, schema, type_str)) { @@ -1626,11 +1771,24 @@ static bool validate_schema_node(validate_context_t* ctx, const cJSON* schema) { valid = false; } } + + const cJSON* extends_node = cJSON_GetObjectItemCaseSensitive(schema, "$extends"); + if (extends_node) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "$extends"); + if (!validate_extends_keyword(ctx, extends_node)) { + valid = false; + } + pop_path(ctx, prev_len); + } /* Validate enum if present */ const cJSON* enum_node = cJSON_GetObjectItemCaseSensitive(schema, "enum"); if (enum_node) { - if (!validate_enum(ctx, enum_node)) valid = false; + size_t prev_len = strlen(ctx->path); + push_path(ctx, "enum"); + if (!validate_enum(ctx, enum_node, type_str)) valid = false; + pop_path(ctx, prev_len); } /* Validate definitions */ @@ -1689,6 +1847,27 @@ static bool validate_root_schema(validate_context_t* ctx, const cJSON* schema) { if (!name) { add_warning(ctx, JS_SCHEMA_ROOT_MISSING_NAME, "Root schema missing name"); } + + if (id && cJSON_IsString(id)) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "$id"); + if (is_blank_string(id->valuestring)) { + add_error(ctx, JS_SCHEMA_KEYWORD_EMPTY, "$id must not be empty"); + valid = false; + } else if (!has_uri_scheme(id->valuestring)) { + add_error(ctx, JS_SCHEMA_CONSTRAINT_VALUE_INVALID, "$id must be a URI with a scheme"); + valid = false; + } + pop_path(ctx, prev_len); + } + + if (name && cJSON_IsString(name) && !is_valid_identifier(name->valuestring)) { + size_t prev_len = strlen(ctx->path); + push_path(ctx, "name"); + add_error(ctx, JS_SCHEMA_NAME_INVALID, "name must be a valid identifier"); + pop_path(ctx, prev_len); + valid = false; + } /* Check for composition keywords at root as alternative to type */ const cJSON* root_ref = cJSON_GetObjectItemCaseSensitive(schema, "$root"); diff --git a/c/tests/test_conformance.c b/c/tests/test_conformance.c index 0b83966..5e63e80 100644 --- a/c/tests/test_conformance.c +++ b/c/tests/test_conformance.c @@ -67,7 +67,7 @@ static char* read_file_content(const char* path) { TEST(invalid_allof_not_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"allOf\": {}" @@ -82,7 +82,7 @@ TEST(invalid_allof_not_array) { TEST(invalid_array_missing_items) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestArray\"," "\"type\": \"array\"" @@ -97,7 +97,7 @@ TEST(invalid_array_missing_items) { TEST(invalid_circular_ref_direct) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Circular\"," "\"type\": \"object\"," @@ -128,7 +128,7 @@ TEST(invalid_circular_ref_direct) { TEST(invalid_defs_not_object) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -144,7 +144,7 @@ TEST(invalid_defs_not_object) { TEST(invalid_enum_duplicates) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -160,7 +160,7 @@ TEST(invalid_enum_duplicates) { TEST(invalid_enum_empty) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -176,7 +176,7 @@ TEST(invalid_enum_empty) { TEST(invalid_enum_not_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -192,7 +192,7 @@ TEST(invalid_enum_not_array) { TEST(invalid_map_missing_values) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestMap\"," "\"type\": \"map\"" @@ -207,7 +207,7 @@ TEST(invalid_map_missing_values) { TEST(invalid_missing_type) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"NoType\"" "}"; @@ -221,7 +221,7 @@ TEST(invalid_missing_type) { TEST(invalid_properties_not_object) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," @@ -237,7 +237,7 @@ TEST(invalid_properties_not_object) { TEST(invalid_ref_undefined) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," @@ -255,7 +255,7 @@ TEST(invalid_ref_undefined) { TEST(invalid_required_missing_property) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," @@ -276,7 +276,7 @@ TEST(invalid_required_missing_property) { TEST(invalid_required_not_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," @@ -295,7 +295,7 @@ TEST(invalid_required_not_array) { TEST(invalid_tuple_missing_definition) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestTuple\"," "\"type\": \"tuple\"" @@ -310,7 +310,7 @@ TEST(invalid_tuple_missing_definition) { TEST(invalid_unknown_type) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"notavalidtype\"" @@ -330,7 +330,7 @@ TEST(invalid_unknown_type) { TEST(valid_with_uses_string_pattern) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"$uses\": [\"JSONStructureValidation\"]," "\"name\": \"PatternString\"," @@ -347,7 +347,7 @@ TEST(valid_with_uses_string_pattern) { TEST(valid_with_uses_numeric_minimum) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"$uses\": [\"JSONStructureValidation\"]," "\"name\": \"PositiveNumber\"," @@ -364,7 +364,7 @@ TEST(valid_with_uses_numeric_minimum) { TEST(valid_with_uses_array_contains) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"$uses\": [\"JSONStructureValidation\"]," "\"name\": \"ArrayWithContains\"," @@ -382,7 +382,7 @@ TEST(valid_with_uses_array_contains) { TEST(valid_with_uses_dependent_required) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"$uses\": [\"JSONStructureValidation\"]," "\"name\": \"WithDependentRequired\"," @@ -418,7 +418,7 @@ TEST(valid_all_primitive_types) { char schema[512]; snprintf(schema, sizeof(schema), "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test%s\"," "\"type\": \"%s\"" @@ -441,7 +441,7 @@ TEST(valid_all_compound_types) { /* object */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestObject\"," "\"type\": \"object\"," @@ -457,7 +457,7 @@ TEST(valid_all_compound_types) { /* array */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestArray\"," "\"type\": \"array\"," @@ -473,7 +473,7 @@ TEST(valid_all_compound_types) { /* set */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestSet\"," "\"type\": \"set\"," @@ -489,7 +489,7 @@ TEST(valid_all_compound_types) { /* map */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestMap\"," "\"type\": \"map\"," @@ -505,7 +505,7 @@ TEST(valid_all_compound_types) { /* tuple */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestTuple\"," "\"type\": \"tuple\"," @@ -525,7 +525,7 @@ TEST(valid_all_compound_types) { /* choice */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestChoice\"," "\"type\": \"choice\"," @@ -545,7 +545,7 @@ TEST(valid_all_compound_types) { /* any */ { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestAny\"," "\"type\": \"any\"" @@ -918,7 +918,7 @@ TEST(instance_uint8_range) { TEST(schema_anyof_not_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"anyOf\": \"not an array\"" @@ -933,7 +933,7 @@ TEST(schema_anyof_not_array) { TEST(schema_oneof_not_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"oneOf\": {}" @@ -948,7 +948,7 @@ TEST(schema_oneof_not_array) { TEST(schema_valid_with_composition) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"allOf\": [{\"type\": \"string\"}]" @@ -963,7 +963,7 @@ TEST(schema_valid_with_composition) { TEST(schema_valid_if_then_else) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," @@ -981,7 +981,7 @@ TEST(schema_valid_if_then_else) { TEST(schema_then_without_if) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -997,7 +997,7 @@ TEST(schema_then_without_if) { TEST(schema_else_without_if) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"string\"," @@ -1013,7 +1013,7 @@ TEST(schema_else_without_if) { TEST(schema_valid_not) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"not\": {\"type\": \"null\"}" @@ -1418,7 +1418,7 @@ TEST(instance_choice_validation) { /* Test tuple instance validation */ TEST(instance_tuple_validation) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Point\"," "\"type\": \"tuple\"," @@ -1453,7 +1453,7 @@ TEST(instance_tuple_validation) { /* Test pattern constraint validation */ TEST(instance_pattern_validation) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Email\"," "\"type\": \"string\"," @@ -1475,7 +1475,7 @@ TEST(instance_pattern_validation) { /* Test contains constraint validation */ TEST(instance_contains_validation) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Numbers\"," "\"type\": \"array\"," @@ -1498,7 +1498,7 @@ TEST(instance_contains_validation) { /* Test prefixItems constraint */ TEST(instance_prefix_items) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Mixed\"," "\"type\": \"array\"," @@ -1523,7 +1523,7 @@ TEST(instance_prefix_items) { /* Test nested union type validation */ TEST(instance_nested_union) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Value\"," "\"type\": [\"string\", \"number\", \"boolean\", \"null\"]" @@ -1545,7 +1545,7 @@ TEST(instance_nested_union) { /* Test deep nesting validation */ TEST(instance_deep_nesting) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Nested\"," "\"type\": \"object\"," @@ -1576,7 +1576,7 @@ TEST(instance_deep_nesting) { /* Test empty object validation */ TEST(instance_empty_object) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Empty\"," "\"type\": \"object\"" @@ -1592,7 +1592,7 @@ TEST(instance_empty_object) { /* Test empty array validation */ TEST(instance_empty_array) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Empty\"," "\"type\": \"array\"," @@ -1609,7 +1609,7 @@ TEST(instance_empty_array) { /* Test minProperties/maxProperties validation */ TEST(instance_object_property_count) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Limited\"," "\"type\": \"object\"," @@ -1650,7 +1650,7 @@ TEST(instance_object_property_count) { /* Test uniqueItems constraint */ TEST(instance_unique_items) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"UniqueList\"," "\"type\": \"array\"," @@ -1686,7 +1686,7 @@ TEST(instance_unique_items) { /* Test $import not allowed when allow_import is false */ TEST(import_not_allowed) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Test\"," "\"type\": \"object\"," diff --git a/c/tests/test_schema_validator.c b/c/tests/test_schema_validator.c index aa697f3..829eb6e 100644 --- a/c/tests/test_schema_validator.c +++ b/c/tests/test_schema_validator.c @@ -37,7 +37,7 @@ static int result_has_message(const js_result_t* result, const char* needle) { TEST(valid_simple_string_schema) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"TestString\"," "\"type\": \"string\"" @@ -54,7 +54,7 @@ TEST(valid_simple_string_schema) { TEST(valid_object_schema) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Person\"," "\"type\": \"object\"," @@ -76,7 +76,7 @@ TEST(valid_object_schema) { TEST(valid_array_schema) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"StringList\"," "\"type\": \"array\"," @@ -94,7 +94,7 @@ TEST(valid_array_schema) { TEST(valid_map_schema) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"StringMap\"," "\"type\": \"map\"," @@ -112,7 +112,7 @@ TEST(valid_map_schema) { TEST(valid_choice_schema) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Shape\"," "\"type\": \"choice\"," @@ -134,7 +134,7 @@ TEST(valid_choice_schema) { TEST(valid_with_definitions) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"Order\"," "\"type\": \"object\"," @@ -163,7 +163,7 @@ TEST(valid_with_definitions) { TEST(valid_with_constraints) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"ConstrainedValues\"," "\"type\": \"object\"," @@ -189,7 +189,7 @@ TEST(valid_with_constraints) { TEST(invalid_missing_type) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"NoType\"" "}"; @@ -214,7 +214,7 @@ TEST(invalid_missing_type) { TEST(invalid_unknown_type) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"UnknownType\"," "\"type\": \"foobar\"" @@ -231,7 +231,7 @@ TEST(invalid_unknown_type) { TEST(invalid_array_missing_items) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"BadArray\"," "\"type\": \"array\"" @@ -248,7 +248,7 @@ TEST(invalid_array_missing_items) { TEST(invalid_map_missing_values) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"BadMap\"," "\"type\": \"map\"" @@ -265,7 +265,7 @@ TEST(invalid_map_missing_values) { TEST(invalid_minlength_exceeds_maxlength) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"BadConstraints\"," "\"type\": \"string\"," @@ -284,7 +284,7 @@ TEST(invalid_minlength_exceeds_maxlength) { TEST(invalid_minimum_exceeds_maximum) { const char* schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," "\"name\": \"BadConstraints\"," "\"type\": \"integer\"," @@ -313,6 +313,126 @@ TEST(invalid_json_syntax) { return !valid ? 0 : 1; } +TEST(invalid_empty_root_id) { + const char* schema = "{" + "\"$id\": \" \"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"EmptyId\"," + "\"type\": \"string\"" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "$id must not be empty"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + +TEST(invalid_root_id_without_scheme) { + const char* schema = "{" + "\"$id\": \"example.com/test\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"NoScheme\"," + "\"type\": \"string\"" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "$id must be a URI with a scheme"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + +TEST(invalid_name_identifier) { + const char* schema = "{" + "\"$id\": \"https://example.com/test\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"bad-name\"," + "\"type\": \"string\"" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "name must be a valid identifier"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + +TEST(invalid_extends_target_type) { + const char* schema = "{" + "\"$id\": \"https://example.com/bad-extends\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"Derived\"," + "\"type\": \"object\"," + "\"$extends\": \"#/definitions/Base\"," + "\"definitions\": {" + "\"Base\": {" + "\"name\": \"Base\"," + "\"type\": \"string\"" + "}" + "}" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "$extends target '#/definitions/Base' must resolve to an object or tuple type"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + +TEST(invalid_tuple_ref_target_not_found) { + const char* schema = "{" + "\"$id\": \"https://example.com/tuple-ref\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"TupleRef\"," + "\"type\": \"tuple\"," + "\"properties\": {" + "\"name\": {\"type\": \"string\"}" + "}," + "\"tuple\": [{\"$ref\": \"#/definitions/Missing\"}]" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "$ref '#/definitions/Missing' not found"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + +TEST(invalid_enum_type_mismatch) { + const char* schema = "{" + "\"$id\": \"https://example.com/enum-type-mismatch\"," + "\"$schema\": \"https://json-structure.org/meta/core/v0/schema\"," + "\"name\": \"EnumTypeMismatch\"," + "\"type\": \"boolean\"," + "\"enum\": [true, \"false\"]" + "}"; + + js_result_t result; + js_result_init(&result); + + bool valid = js_validate_schema(schema, &result); + int ok = !valid && result_has_message(&result, "enum value is not valid for type 'boolean'"); + + js_result_cleanup(&result); + return ok ? 0 : 1; +} + /* ============================================================================ * Type Checking Tests * ============================================================================ */ @@ -347,7 +467,7 @@ TEST(is_valid_compound_type) { TEST(placeholder_ucum_unit_keyword_coverage) { const char* valid_schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," "\"name\": \"Length\"," "\"$uses\": [\"JSONStructureUnits\"]," @@ -355,14 +475,14 @@ TEST(placeholder_ucum_unit_keyword_coverage) { "\"ucumUnit\": \"m\"" "}"; const char* invalid_type_schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," "\"name\": \"BadUcumType\"," "\"type\": \"string\"," "\"ucumUnit\": \"m\"" "}"; const char* invalid_value_schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," "\"name\": \"BadUcumValue\"," "\"$uses\": [\"JSONStructureUnits\"]," @@ -403,7 +523,7 @@ TEST(placeholder_ucum_unit_keyword_coverage) { TEST(placeholder_relations_extension_coverage) { const char* valid_schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," "\"name\": \"Order\"," "\"$uses\": [\"JSONStructureRelations\"]," @@ -434,7 +554,7 @@ TEST(placeholder_relations_extension_coverage) { "}" "}"; const char* invalid_schema = "{" - "\"$id\": \"test\"," + "\"$id\": \"https://example.com/test\"," "\"$schema\": \"https://json-structure.org/meta/extended/v0/#\"," "\"name\": \"BadRelations\"," "\"type\": \"string\"," @@ -503,6 +623,12 @@ int test_schema_validator(void) { RUN_TEST(invalid_minlength_exceeds_maxlength); RUN_TEST(invalid_minimum_exceeds_maximum); RUN_TEST(invalid_json_syntax); + RUN_TEST(invalid_empty_root_id); + RUN_TEST(invalid_root_id_without_scheme); + RUN_TEST(invalid_name_identifier); + RUN_TEST(invalid_extends_target_type); + RUN_TEST(invalid_tuple_ref_target_not_found); + RUN_TEST(invalid_enum_type_mismatch); RUN_TEST(placeholder_ucum_unit_keyword_coverage); RUN_TEST(placeholder_relations_extension_coverage); diff --git a/dotnet/src/JsonStructure/Validation/ErrorCodes.cs b/dotnet/src/JsonStructure/Validation/ErrorCodes.cs index 92d4ebe..2e27c4f 100644 --- a/dotnet/src/JsonStructure/Validation/ErrorCodes.cs +++ b/dotnet/src/JsonStructure/Validation/ErrorCodes.cs @@ -65,6 +65,12 @@ public static class ErrorCodes /// Name is not a valid identifier. public const string SchemaNameInvalid = "SCHEMA_NAME_INVALID"; + /// Constraint value has an invalid type for the schema type. + public const string SchemaConstraintTypeMismatch = "SCHEMA_CONSTRAINT_TYPE_MISMATCH"; + + /// Constraint value is invalid. + public const string SchemaConstraintValueInvalid = "SCHEMA_CONSTRAINT_VALUE_INVALID"; + /// Constraint is not valid for this type. public const string SchemaConstraintInvalidForType = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE"; diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 5440f2d..7dc9d03 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; @@ -93,9 +94,20 @@ public sealed class SchemaValidator RegexOptions.Compiled); private static readonly Regex IdentifierPattern = new( - @"^[a-zA-Z_][a-zA-Z0-9_]*$", + @"^[A-Za-z_$][A-Za-z0-9_$]*$", RegexOptions.Compiled); + private static readonly Regex UriSchemePattern = new( + @"^[a-zA-Z][a-zA-Z0-9+\-.]*:", + RegexOptions.Compiled); + + private static readonly HashSet EnumNumericTypes = new(StringComparer.Ordinal) + { + "integer", "int8", "int16", "int32", "int64", + "uint8", "uint16", "uint32", "uint64", + "float", "double", "decimal" + }; + /// /// Internal context for a single validation operation. /// This isolates mutable state per validation call, enabling thread safety. @@ -571,6 +583,10 @@ private void ValidateSchemaCore(JsonNode node, ValidationResult result, string p { AddError(result, ErrorCodes.SchemaRootMissingName, "Root schema with 'type' must have a 'name' property", ""); } + else if (schema.TryGetPropertyValue("name", out var nameValue)) + { + ValidateIdentifier(nameValue, "name", path, result); + } } // Validate $schema if present @@ -591,6 +607,17 @@ private void ValidateSchemaCore(JsonNode node, ValidationResult result, string p if (schema.TryGetPropertyValue("$id", out var idValue)) { ValidateStringProperty(idValue, "$id", path, result); + if (idValue is JsonValue idJsonValue && idJsonValue.TryGetValue(out var id)) + { + if (string.IsNullOrWhiteSpace(id)) + { + AddError(result, ErrorCodes.SchemaKeywordEmpty, "$id must not be empty", AppendPath(path, "$id")); + } + else if (!UriSchemePattern.IsMatch(id)) + { + AddError(result, ErrorCodes.SchemaConstraintValueInvalid, "$id must be a URI with a scheme", AppendPath(path, "$id")); + } + } } // Check for bare $ref - this is NOT permitted per spec Section 3.4.1 @@ -718,7 +745,7 @@ private void ValidateSchemaCore(JsonNode node, ValidationResult result, string p // Validate enum if present if (schema.TryGetPropertyValue("enum", out var enumValue)) { - ValidateEnum(enumValue, path, result); + ValidateEnum(enumValue, typeStr, path, result); } // Validate const if present - const value must be conformant with the type definition @@ -764,8 +791,7 @@ private void ValidateIdentifier(JsonNode? value, string keyword, string path, Va if (!IdentifierPattern.IsMatch(str)) { - AddError(result, ErrorCodes.SchemaNameInvalid, $"{keyword} must be a valid identifier (start with letter or underscore, contain only letters, digits, underscores)", - AppendPath(path, keyword)); + AddError(result, ErrorCodes.SchemaNameInvalid, $"{keyword} must be a valid identifier", AppendPath(path, keyword)); } } @@ -850,10 +876,25 @@ private void ValidateExtendsKeyword(JsonNode? value, string path, ValidationResu { AddError(result, ErrorCodes.SchemaExtendsNotFound, $"$extends reference '{refStr}' not found", refPath); } - else if (resolved is JsonObject resolvedObj && resolvedObj.TryGetPropertyValue("$extends", out var nestedExtends)) + else if (resolved is not JsonObject resolvedObj) { - // Recursively validate the extended schema's $extends - ValidateExtendsKeyword(nestedExtends, refPath, result); + AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must resolve to an object or tuple type", refPath); + } + else + { + var resolvedType = resolvedObj.TryGetPropertyValue("type", out var resolvedTypeValue) + ? GetTypeString(resolvedTypeValue) + : null; + + if (resolvedType is not ("object" or "tuple")) + { + AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must resolve to an object or tuple type", refPath); + } + else if (resolvedObj.TryGetPropertyValue("$extends", out var nestedExtends)) + { + // Recursively validate the extended schema's $extends + ValidateExtendsKeyword(nestedExtends, refPath, result); + } } ctx.VisitedExtends.Remove(refStr); @@ -1578,12 +1619,26 @@ private void ValidateTupleSchema(JsonObject schema, string path, ValidationResul } else if (schema.TryGetPropertyValue("properties", out var propsValue) && propsValue is JsonObject props) { - foreach (var propName in tupleArr) + for (int i = 0; i < tupleArr.Count; i++) { - var name = propName?.GetValue(); - if (name is not null && props.TryGetPropertyValue(name, out var propSchema)) + var tupleEntry = tupleArr[i]; + if (tupleEntry is JsonValue propNameValue && propNameValue.TryGetValue(out var name)) { - ValidateSchemaCore(propSchema!, result, AppendPath(path, $"properties/{name}"), depth + 1, visitedRefs); + if (props.TryGetPropertyValue(name, out var propSchema)) + { + ValidateSchemaCore(propSchema!, result, AppendPath(path, $"properties/{name}"), depth + 1, visitedRefs); + } + + continue; + } + + if (tupleEntry is JsonObject refObject && refObject.TryGetPropertyValue("$ref", out var refValue)) + { + ValidateReference(refValue, "$ref", $"{AppendPath(path, "tuple")}[{i}]", result); + if (refValue is JsonValue refJsonValue && refJsonValue.TryGetValue(out var refStr) && ResolveLocalRef(refStr) is null) + { + AddError(result, ErrorCodes.SchemaRefNotFound, $"$ref target does not exist: {refStr}", $"{AppendPath(path, "tuple")}[{i}]/$ref"); + } } } } @@ -1776,7 +1831,7 @@ private void ValidateNumericSchema(JsonObject schema, string path, ValidationRes } } - private void ValidateEnum(JsonNode? value, string path, ValidationResult result) + private void ValidateEnum(JsonNode? value, string? typeStr, string path, ValidationResult result) { if (value is not JsonArray arr) { @@ -1801,6 +1856,33 @@ private void ValidateEnum(JsonNode? value, string path, ValidationResult result) break; } } + + if (string.IsNullOrEmpty(typeStr)) + { + return; + } + + foreach (var item in arr) + { + if (!IsEnumValueValidForType(item, typeStr)) + { + AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"enum value is not valid for type '{typeStr}'", AppendPath(path, "enum")); + break; + } + } + } + + private static bool IsEnumValueValidForType(JsonNode? value, string typeStr) + { + var valueKind = value?.GetValueKind() ?? JsonValueKind.Null; + return typeStr switch + { + "string" => valueKind == JsonValueKind.String, + "boolean" => valueKind is JsonValueKind.True or JsonValueKind.False, + "null" => valueKind == JsonValueKind.Null, + _ when EnumNumericTypes.Contains(typeStr) => valueKind == JsonValueKind.Number, + _ => true + }; } private void ValidateConstValue(JsonNode? constValue, string? typeStr, string path, ValidationResult result) diff --git a/go/error_codes.go b/go/error_codes.go index 4816f16..4efc347 100644 --- a/go/error_codes.go +++ b/go/error_codes.go @@ -76,6 +76,8 @@ const ( SchemaConstraintInvalidForType = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE" // SchemaConstraintTypeMismatch indicates a keyword is used with an incompatible schema type. SchemaConstraintTypeMismatch = "SCHEMA_CONSTRAINT_TYPE_MISMATCH" + // SchemaConstraintValueInvalid indicates a keyword has an invalid value. + SchemaConstraintValueInvalid = "SCHEMA_CONSTRAINT_VALUE_INVALID" // SchemaMinGreaterThanMax indicates minimum cannot be greater than maximum. SchemaMinGreaterThanMax = "SCHEMA_MIN_GREATER_THAN_MAX" // SchemaPropertiesNotObject indicates properties must be an object. diff --git a/go/schema_validator.go b/go/schema_validator.go index ed1f3c8..2c28898 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -143,6 +143,11 @@ var validationExtensionKeywords = map[string]bool{ "has": true, "default": true, } +var ( + uriSchemePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+\-.]*:`) + identifierPattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) +) + func hasExtension(schema map[string]interface{}, extension string) bool { if uses, ok := schema["$uses"].([]interface{}); ok { for _, use := range uses { @@ -154,8 +159,18 @@ func hasExtension(schema map[string]interface{}, extension string) bool { return false } +func (ctx *schemaValidationContext) hasExtensionEnabled(schema map[string]interface{}, extension string) bool { + if hasExtension(schema, extension) { + return true + } + if ctx.schema != nil { + return hasExtension(ctx.schema, extension) + } + return false +} + func (ctx *schemaValidationContext) validateUnitsKeywords(schema map[string]interface{}, path string) { - unitsEnabled := hasExtension(schema, "JSONStructureUnits") + unitsEnabled := ctx.hasExtensionEnabled(schema, "JSONStructureUnits") for _, keyword := range []string{"unit", "ucumUnit", "currency", "symbols"} { value, ok := schema[keyword] if !ok { @@ -188,7 +203,7 @@ func (ctx *schemaValidationContext) validateRelationsKeywords(schema map[string] return } - if !hasExtension(schema, "JSONStructureRelations") { + if !ctx.hasExtensionEnabled(schema, "JSONStructureRelations") { if hasIdentity { ctx.addError(path+"/identity", "identity requires JSONStructureRelations in $uses", SchemaExtensionKeywordNotEnabled) } @@ -301,15 +316,27 @@ func (ctx *schemaValidationContext) validateSchemaDocument(schema map[string]int isRoot := path == "#" if isRoot { // Root schema must have $id - if _, hasID := schema["$id"]; !hasID { + if idVal, hasID := schema["$id"]; !hasID { ctx.addError("", "Missing required '$id' keyword at root", SchemaRootMissingID) + } else if idStr, ok := idVal.(string); ok { + trimmedID := strings.TrimSpace(idStr) + if trimmedID == "" { + ctx.addError(path+"/$id", "$id must not be empty", SchemaKeywordEmpty) + } else if !uriSchemePattern.MatchString(trimmedID) { + ctx.addError(path+"/$id", "$id must be a URI with a scheme", SchemaConstraintValueInvalid) + } } // Root schema with 'type' must have 'name' _, hasType := schema["type"] - _, hasName := schema["name"] + nameVal, hasName := schema["name"] if hasType && !hasName { ctx.addError("", "Root schema with 'type' must have a 'name' property", SchemaRootMissingName) + } else if hasType { + nameStr, ok := nameVal.(string) + if !ok || !identifierPattern.MatchString(nameStr) { + ctx.addError(path+"/name", "name must be a valid identifier", SchemaNameInvalid) + } } } @@ -669,13 +696,30 @@ func (ctx *schemaValidationContext) validateTupleType(schema map[string]interfac propsMap, _ := schema["properties"].(map[string]interface{}) for i, elem := range tupleArr { - name, isStr := elem.(string) - if !isStr { - ctx.addError(fmt.Sprintf("%s/tuple[%d]", path, i), "tuple elements must be strings", SchemaKeywordInvalidType) - } else if propsMap != nil { - if _, exists := propsMap[name]; !exists { - ctx.addError(fmt.Sprintf("%s/tuple[%d]", path, i), fmt.Sprintf("Tuple element '%s' not found in properties", name), SchemaRequiredPropertyNotDefined) + elemPath := fmt.Sprintf("%s/tuple[%d]", path, i) + switch tupleElem := elem.(type) { + case string: + if propsMap != nil { + if _, exists := propsMap[tupleElem]; !exists { + ctx.addError(elemPath, fmt.Sprintf("Tuple element '%s' not found in properties", tupleElem), SchemaRequiredPropertyNotDefined) + } + } + case map[string]interface{}: + refVal, hasRef := tupleElem["$ref"] + if !hasRef { + ctx.addError(elemPath, "tuple elements must be strings or $ref objects", SchemaKeywordInvalidType) + continue + } + refStr, ok := refVal.(string) + if !ok { + ctx.addError(elemPath+"/$ref", "$ref must be a string", SchemaKeywordInvalidType) + continue } + if ctx.resolveRef(refStr) == nil { + ctx.addError(elemPath+"/$ref", fmt.Sprintf("$ref '%s' not found", refStr), SchemaRefNotFound) + } + default: + ctx.addError(elemPath, "tuple elements must be strings or $ref objects", SchemaKeywordInvalidType) } } } @@ -702,6 +746,23 @@ func (ctx *schemaValidationContext) validateChoiceType(schema map[string]interfa } } +func isEnumValueValidForType(typeStr string, value interface{}) bool { + switch { + case typeStr == "string": + _, ok := value.(string) + return ok + case isNumericType(typeStr): + return isNumber(value) + case typeStr == "boolean": + _, ok := value.(bool) + return ok + case typeStr == "null": + return value == nil + default: + return true + } +} + func (ctx *schemaValidationContext) validatePrimitiveConstraints(typeStr string, schema map[string]interface{}, path string) { // Validate enum if enumVal, ok := schema["enum"]; ok { @@ -720,6 +781,9 @@ func (ctx *schemaValidationContext) validatePrimitiveConstraints(typeStr string, break } seen[string(serialized)] = true + if !isEnumValueValidForType(typeStr, enumArr[i]) { + ctx.addError(fmt.Sprintf("%s/enum[%d]", path, i), fmt.Sprintf("enum value is not valid for type '%s'", typeStr), SchemaConstraintTypeMismatch) + } } } } @@ -983,9 +1047,14 @@ func (ctx *schemaValidationContext) validateExtends(extendsVal interface{}, path resolved := ctx.resolveRef(ref) if resolved == nil { ctx.addError(refPath, fmt.Sprintf("$extends reference '%s' not found", ref), SchemaExtendsNotFound) - } else if extendsVal, hasExtends := resolved["$extends"]; hasExtends { - // Recursively validate the extended schema's $extends - ctx.validateExtends(extendsVal, refPath) + } else { + resolvedType, _ := resolved["type"].(string) + if resolvedType != "object" && resolvedType != "tuple" { + ctx.addError(refPath, fmt.Sprintf("$extends target '%s' must resolve to an object or tuple type", ref), SchemaConstraintTypeMismatch) + } else if extendsVal, hasExtends := resolved["$extends"]; hasExtends { + // Recursively validate the extended schema's $extends + ctx.validateExtends(extendsVal, refPath) + } } delete(ctx.seenExtends, ref) } diff --git a/go/validators_edge_cases_test.go b/go/validators_edge_cases_test.go index 34f4047..0d4304e 100644 --- a/go/validators_edge_cases_test.go +++ b/go/validators_edge_cases_test.go @@ -4,6 +4,15 @@ import ( "testing" ) +func hasSchemaErrorCode(result ValidationResult, code string) bool { + for _, err := range result.Errors { + if err.Code == code { + return true + } + } + return false +} + // ============================================================================ // Schema Validator Additional Tests // ============================================================================ @@ -12,9 +21,9 @@ func TestSchemaValidatorRequiredArray(t *testing.T) { validator := NewSchemaValidator(&SchemaValidatorOptions{Extended: true}) tests := []struct { - name string + name string schema map[string]interface{} - valid bool + valid bool }{ { name: "valid required array", @@ -222,6 +231,99 @@ func TestSchemaValidatorMapValues(t *testing.T) { } } +func TestSchemaValidatorRemainingGaps(t *testing.T) { + validator := NewSchemaValidator(&SchemaValidatorOptions{Extended: true}) + + tests := []struct { + name string + schema map[string]interface{} + expectedCode string + }{ + { + name: "extends target must be object or tuple", + schema: map[string]interface{}{ + "$id": "urn:example:test", + "name": "Test", + "type": "object", + "properties": map[string]interface{}{ + "child": map[string]interface{}{ + "type": "object", + "$extends": "#/definitions/BaseString", + "properties": map[string]interface{}{ + "value": map[string]interface{}{"type": "string"}, + }, + }, + }, + "definitions": map[string]interface{}{ + "BaseString": map[string]interface{}{"type": "string"}, + }, + }, + expectedCode: SchemaConstraintTypeMismatch, + }, + { + name: "tuple ref must resolve", + schema: map[string]interface{}{ + "$id": "urn:example:test", + "name": "Test", + "type": "tuple", + "tuple": []interface{}{ + map[string]interface{}{"$ref": "#/definitions/Missing"}, + }, + }, + expectedCode: SchemaRefNotFound, + }, + { + name: "empty id rejected", + schema: map[string]interface{}{ + "$id": " ", + "name": "Test", + "type": "string", + }, + expectedCode: SchemaKeywordEmpty, + }, + { + name: "id must have scheme", + schema: map[string]interface{}{ + "$id": "example-without-scheme", + "name": "Test", + "type": "string", + }, + expectedCode: SchemaConstraintValueInvalid, + }, + { + name: "name must be valid identifier", + schema: map[string]interface{}{ + "$id": "urn:example:test", + "name": "123-invalid", + "type": "string", + }, + expectedCode: SchemaNameInvalid, + }, + { + name: "enum values must match type", + schema: map[string]interface{}{ + "$id": "urn:example:test", + "name": "Test", + "type": "string", + "enum": []interface{}{"ok", 1}, + }, + expectedCode: SchemaConstraintTypeMismatch, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := validator.Validate(tc.schema) + if result.IsValid { + t.Fatalf("Expected invalid schema") + } + if !hasSchemaErrorCode(result, tc.expectedCode) { + t.Fatalf("Expected error code %s, got errors: %v", tc.expectedCode, result.Errors) + } + }) + } +} + // ============================================================================ // Instance Validator Edge Cases // ============================================================================ diff --git a/java/src/main/java/org/json_structure/validation/ErrorCodes.java b/java/src/main/java/org/json_structure/validation/ErrorCodes.java index 9355346..cece381 100644 --- a/java/src/main/java/org/json_structure/validation/ErrorCodes.java +++ b/java/src/main/java/org/json_structure/validation/ErrorCodes.java @@ -54,6 +54,8 @@ private ErrorCodes() { public static final String SCHEMA_CONSTRAINT_INVALID_FOR_TYPE = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE"; /** Keyword is used with an incompatible schema type. */ public static final String SCHEMA_CONSTRAINT_TYPE_MISMATCH = "SCHEMA_CONSTRAINT_TYPE_MISMATCH"; + /** Constraint value is invalid. */ + public static final String SCHEMA_CONSTRAINT_VALUE_INVALID = "SCHEMA_CONSTRAINT_VALUE_INVALID"; /** Minimum cannot be greater than maximum. */ public static final String SCHEMA_MIN_GREATER_THAN_MAX = "SCHEMA_MIN_GREATER_THAN_MAX"; /** Properties must be an object. */ diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index f712cde..b61b605 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -111,6 +111,12 @@ public final class SchemaValidator { private static final Pattern IDENTIFIER_PATTERN = Pattern.compile( "^[a-zA-Z_][a-zA-Z0-9_]*$"); + private static final Pattern ID_URI_SCHEME_PATTERN = Pattern.compile( + "^[a-zA-Z][a-zA-Z0-9+\\-.]*:"); + + private static final Pattern NAME_IDENTIFIER_PATTERN = Pattern.compile( + "^[A-Za-z_$][A-Za-z0-9_$]*$"); + /** * Validation extension keywords that require the JSONStructureValidation feature. */ @@ -504,7 +510,20 @@ private void validateSchemaCore(JsonNode node, ValidationResult result, String p // Validate $id if present if (schema.has("$id")) { - validateStringProperty(schema.get("$id"), "$id", path, result); + JsonNode idValue = schema.get("$id"); + validateStringProperty(idValue, "$id", path, result); + if (isRoot && idValue != null && idValue.isTextual()) { + String idText = idValue.asText(); + if (idText.trim().isEmpty()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_EMPTY, "$id must not be empty", appendPath(path, "$id")); + } else if (!ID_URI_SCHEME_PATTERN.matcher(idText).find()) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_VALUE_INVALID, "$id must be a URI with a scheme", appendPath(path, "$id")); + } + } + } + + if (schema.has("name")) { + validateNameProperty(schema.get("name"), path, result); } // Check for bare $ref - this is NOT permitted per spec Section 3.4.1 @@ -576,7 +595,7 @@ private void validateSchemaCore(JsonNode node, ValidationResult result, String p // Validate enum if present if (schema.has("enum")) { - validateEnum(schema.get("enum"), path, result); + validateEnum(schema.get("enum"), typeStr, path, result); } // Validate conditional composition keywords @@ -697,6 +716,17 @@ private void validateIdentifier(JsonNode value, String keyword, String path, Val } } + private void validateNameProperty(JsonNode value, String path, ValidationResult result) { + if (value == null || !value.isTextual()) { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, "name must be a string", appendPath(path, "name")); + return; + } + + if (!NAME_IDENTIFIER_PATTERN.matcher(value.asText()).matches()) { + addError(result, ErrorCodes.SCHEMA_NAME_INVALID, "name must be a valid identifier", appendPath(path, "name")); + } + } + private void validateReference(JsonNode value, String keyword, String path, ValidationResult result) { if (value == null || !value.isTextual()) { addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, keyword + " must be a string", appendPath(path, keyword)); @@ -750,11 +780,12 @@ private void validateExtendsKeyword(JsonNode value, String path, ValidationResul */ private void validateExtendsRef(String refStr, String path, ValidationResult result) { SchemaValidationContext ctx = currentContext.get(); - + String refPath = appendPath(path, "$extends"); + // Check for circular reference if (ctx.visitedExtends.contains(refStr)) { - addError(result, ErrorCodes.SCHEMA_EXTENDS_CIRCULAR, - "Circular $extends reference detected: " + refStr, appendPath(path, "$extends")); + addError(result, ErrorCodes.SCHEMA_EXTENDS_CIRCULAR, + "Circular $extends reference detected: " + refStr, refPath); return; } @@ -762,9 +793,19 @@ private void validateExtendsRef(String refStr, String path, ValidationResult res ctx.visitedExtends.add(refStr); // Resolve and validate the base schema - JsonNode baseSchema = resolveLocalRef(refStr); - if (baseSchema != null && baseSchema.isObject() && baseSchema.has("$extends")) { - validateExtendsKeyword(baseSchema.get("$extends"), refStr, result); + JsonNode baseSchema = resolveRef(refStr); + if (baseSchema == null) { + addError(result, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND, "$extends reference '" + refStr + "' not found", refPath); + } else { + JsonNode baseType = baseSchema.get("type"); + String baseTypeStr = baseType != null && baseType.isTextual() ? baseType.asText() : null; + boolean isNamespaceMap = "map".equals(baseTypeStr) && refStr.endsWith("/Namespace"); + if (!"object".equals(baseTypeStr) && !"tuple".equals(baseTypeStr) && !isNamespaceMap) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "$extends target '" + refStr + "' must resolve to an object or tuple type", refPath); + } else if (baseSchema.isObject() && baseSchema.has("$extends")) { + validateExtendsKeyword(baseSchema.get("$extends"), refStr, result); + } } // Unmark after processing @@ -920,6 +961,25 @@ private void validateType(JsonNode value, String path, ValidationResult result, addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, "type must be a string, array of strings, or object with $ref", typePath); } + private JsonNode resolveRef(String refStr) { + if (refStr == null || refStr.isBlank()) { + return null; + } + + if (refStr.startsWith("#/")) { + return resolveLocalRef(refStr); + } + + if (options.getReferenceResolver() != null) { + JsonNode resolved = options.getReferenceResolver().apply(refStr); + if (resolved != null) { + return resolved; + } + } + + return externalSchemaMap.get(refStr); + } + /** * Validates that a $ref string actually resolves to a valid target in the schema. */ @@ -927,9 +987,9 @@ private void validateRefResolution(String refStr, String path, ValidationResult if (refStr == null || refStr.isBlank()) { return; // Already handled by validateReference } - + if (refStr.startsWith("#/")) { - JsonNode resolved = resolveLocalRef(refStr); + JsonNode resolved = resolveRef(refStr); if (resolved == null) { // Check if this ref points into an import namespace SchemaValidationContext ctx = currentContext.get(); @@ -1088,15 +1148,27 @@ private void validateTupleSchema(ObjectNode schema, String path, ValidationResul } else { // Validate that all items in tuple array refer to valid properties ObjectNode properties = (ObjectNode) schema.get("properties"); + int index = 0; for (JsonNode item : tupleNode) { - if (!item.isTextual()) { - addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, "tuple array must contain strings", appendPath(path, "tuple")); - } else { + String tupleItemPath = appendPath(path, "tuple/" + index); + if (item.isTextual()) { String propName = item.asText(); if (!properties.has(propName)) { - addError(result, ErrorCodes.SCHEMA_REF_NOT_FOUND, "tuple references undefined property: " + propName, appendPath(path, "tuple")); + addError(result, ErrorCodes.SCHEMA_REF_NOT_FOUND, "tuple references undefined property: " + propName, tupleItemPath); + } + } else if (item.isObject() && item.has("$ref")) { + JsonNode refNode = item.get("$ref"); + validateReference(refNode, "$ref", tupleItemPath, result); + if (refNode != null && refNode.isTextual()) { + String refStr = refNode.asText(); + if (resolveRef(refStr) == null) { + addError(result, ErrorCodes.SCHEMA_REF_NOT_FOUND, "$ref target does not exist: " + refStr, appendPath(tupleItemPath, "$ref")); + } } + } else { + addError(result, ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE, "tuple array must contain strings or objects with $ref", tupleItemPath); } + index++; } } @@ -1243,7 +1315,7 @@ private void validateNumericSchema(ObjectNode schema, String path, ValidationRes } } - private void validateEnum(JsonNode value, String path, ValidationResult result) { + private void validateEnum(JsonNode value, String typeStr, String path, ValidationResult result) { if (!value.isArray()) { addError(result, ErrorCodes.SCHEMA_ENUM_NOT_ARRAY, "enum must be an array", appendPath(path, "enum")); return; @@ -1254,15 +1326,30 @@ private void validateEnum(JsonNode value, String path, ValidationResult result) addError(result, ErrorCodes.SCHEMA_ENUM_EMPTY, "enum array cannot be empty", appendPath(path, "enum")); return; } - + // Check for duplicates Set seen = new HashSet<>(); + int index = 0; for (JsonNode item : arr) { String itemStr = item.toString(); // Use JSON representation for comparison if (!seen.add(itemStr)) { addError(result, ErrorCodes.SCHEMA_ENUM_DUPLICATES, "enum contains duplicate values", appendPath(path, "enum")); break; } + + if (typeStr != null && !COMPOUND_TYPES.contains(typeStr)) { + boolean isValid = switch (typeStr) { + case "string" -> item.isTextual(); + case "boolean" -> item.isBoolean(); + case "null" -> item.isNull(); + default -> isNumericType(typeStr) && item.isNumber(); + }; + if (!isValid) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "enum value is not valid for type '" + typeStr + "'", appendPath(path, "enum/" + index)); + } + } + index++; } } @@ -1383,8 +1470,19 @@ private boolean hasExtension(JsonNode schema, String extensionName) { return false; } + private boolean hasExtensionEnabled(JsonNode schema, String extensionName) { + if (hasExtension(schema, extensionName)) { + return true; + } + SchemaValidationContext ctx = currentContext.get(); + if (ctx != null && ctx.rootSchema != null) { + return hasExtension(ctx.rootSchema, extensionName); + } + return false; + } + private void validateUnitsKeywords(ObjectNode schema, String typeStr, String path, ValidationResult result) { - boolean unitsEnabled = hasExtension(schema, "JSONStructureUnits"); + boolean unitsEnabled = hasExtensionEnabled(schema, "JSONStructureUnits"); for (String keyword : List.of("unit", "ucumUnit", "currency", "symbols")) { if (!schema.has(keyword)) { @@ -1420,7 +1518,7 @@ private void validateRelationsKeywords(ObjectNode schema, String typeStr, String return; } - boolean relationsEnabled = hasExtension(schema, "JSONStructureRelations"); + boolean relationsEnabled = hasExtensionEnabled(schema, "JSONStructureRelations"); if (hasIdentity && !relationsEnabled) { addError(result, ErrorCodes.SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED, "identity requires 'JSONStructureRelations' in $uses", appendPath(path, "identity")); diff --git a/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java b/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java index 57bee46..c8fca85 100644 --- a/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java +++ b/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java @@ -580,6 +580,130 @@ void choiceTypeRequiresChoices() { assertThat(validator.validate(invalidSchema).isValid()).isFalse(); } + @Test + @DisplayName("Invalid root $id is rejected when empty") + void invalidRootIdEmpty() { + String schema = """ + { + "$id": " ", + "name": "TestSchema", + "type": "object", + "properties": {} + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_KEYWORD_EMPTY.equals(e.getCode()) && "$id must not be empty".equals(e.getMessage())); + } + + @Test + @DisplayName("Invalid root $id without scheme is rejected") + void invalidRootIdWithoutScheme() { + String schema = """ + { + "$id": "example.com/schema/no-scheme", + "name": "TestSchema", + "type": "object", + "properties": {} + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_CONSTRAINT_VALUE_INVALID.equals(e.getCode()) && "$id must be a URI with a scheme".equals(e.getMessage())); + } + + @Test + @DisplayName("Invalid name identifier is rejected") + void invalidNameIdentifier() { + String schema = """ + { + "$id": "https://test.example.com/schema/invalidName", + "name": "1invalid-name", + "type": "object", + "properties": {} + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_NAME_INVALID.equals(e.getCode()) && "name must be a valid identifier".equals(e.getMessage())); + } + + @Test + @DisplayName("$extends target must be object or tuple") + void extendsTargetMustBeObjectOrTuple() { + String schema = """ + { + "$id": "https://test.example.com/schema/extendsTypeMismatch", + "name": "TestSchema", + "definitions": { + "Base": { + "name": "Base", + "type": "string" + } + }, + "type": "object", + "$extends": "#/definitions/Base", + "properties": {} + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH.equals(e.getCode()) && + e.getMessage().equals("$extends target '#/definitions/Base' must resolve to an object or tuple type")); + } + + @Test + @DisplayName("Tuple $ref entries must resolve") + void tupleRefEntriesMustResolve() { + String schema = """ + { + "$id": "https://test.example.com/schema/tupleRefMissing", + "name": "TestSchema", + "type": "tuple", + "properties": { + "first": { "type": "string" } + }, + "tuple": [ + "first", + { "$ref": "#/definitions/Missing" } + ] + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_REF_NOT_FOUND.equals(e.getCode()) && + e.getMessage().equals("$ref target does not exist: #/definitions/Missing")); + } + + @Test + @DisplayName("Enum values must match declared type") + void enumValuesMustMatchDeclaredType() { + String schema = """ + { + "$id": "https://test.example.com/schema/enumTypeMismatch", + "name": "TestSchema", + "type": "boolean", + "enum": [true, "false"] + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrors()).anyMatch(e -> + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH.equals(e.getCode()) && + e.getMessage().equals("enum value is not valid for type 'boolean'")); + } + // === Extension Keyword Warning Tests === @Test diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 1c8dd5f..3a720b7 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -456,6 +456,19 @@ sub _check_required_top_level_keywords { $self->_add_error( SCHEMA_ROOT_MISSING_ID, "Missing required '\$id' keyword at root", $location ); } + elsif ( defined $obj->{'$id'} && !ref( $obj->{'$id'} ) ) { + if ( $obj->{'$id'} eq '' ) { + $self->_add_error( SCHEMA_KEYWORD_EMPTY, + '\$id must not be empty', '#/$id' ); + } + elsif ( $obj->{'$id'} !~ /^[a-zA-Z][a-zA-Z0-9+\-.]*:/ ) { + $self->_add_error( + SCHEMA_CONSTRAINT_VALUE_INVALID, + '\$id must be a URI with a scheme', + '#/$id' + ); + } + } # Root schema with 'type' must have 'name' if ( exists $obj->{type} && !exists $obj->{name} ) { @@ -788,14 +801,12 @@ sub _validate_schema { # Validate name if present if ( exists $schema->{name} ) { - my $id_regex = - $self->{allow_dollar} ? $IDENTIFIER_DOLLAR_REGEX : $IDENTIFIER_REGEX; if ( !defined $schema->{name} || ref( $schema->{name} ) - || $schema->{name} !~ $id_regex ) + || $schema->{name} !~ $IDENTIFIER_DOLLAR_REGEX ) { $self->_add_error( SCHEMA_NAME_INVALID, - "'name' must be a valid identifier", "$path/name" ); + 'name must be a valid identifier', "$path/name" ); } } @@ -873,7 +884,7 @@ sub _validate_schema { # Validate enum if ( exists $schema->{enum} ) { - $self->_validate_enum( $schema->{enum}, "$path/enum" ); + $self->_validate_enum( $schema->{enum}, "$path/enum", $schema->{type} ); } # Validate const @@ -1233,10 +1244,25 @@ sub _validate_tuple { return; } - # Validate each tuple entry exists in properties + # Validate each tuple entry exists in properties or resolves via $ref if ( defined $properties && ref($properties) eq 'HASH' ) { for my $i ( 0 .. $#$tuple ) { my $prop = $tuple->[$i]; + + if ( ref($prop) eq 'HASH' && exists $prop->{'$ref'} ) { + my $ref = $prop->{'$ref'}; + my $target = + defined $ref && !ref($ref) + ? $self->_resolve_json_pointer( $ref, $self->{doc} ) + : undef; + if ( !defined $target ) { + $self->_add_error( SCHEMA_REF_NOT_FOUND, + "\$ref target does not exist: $ref", + "$path/tuple[$i]/\$ref" ); + } + next; + } + if ( !defined $prop || ref($prop) ) { $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE, "tuple[$i] must be a string", @@ -1256,7 +1282,7 @@ sub _validate_tuple { } sub _validate_enum { - my ( $self, $enum, $path ) = @_; + my ( $self, $enum, $path, $type ) = @_; if ( ref($enum) ne 'ARRAY' ) { $self->_add_error( SCHEMA_ENUM_NOT_ARRAY, 'enum must be an array', @@ -1283,6 +1309,36 @@ sub _validate_enum { } $seen{$key} = 1; } + + if ( defined $type && !ref($type) && !exists $COMPOUND_TYPES{$type} ) { + for my $i ( 0 .. $#$enum ) { + next if _enum_value_matches_type( $enum->[$i], $type ); + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "enum value at index $i does not match declared type '$type'", + "$path\[$i]" + ); + } + } +} + +sub _enum_value_matches_type { + my ( $value, $type ) = @_; + + return !defined $value if $type eq 'null'; + return _is_json_bool($value) if $type eq 'boolean'; + return defined $value + && !ref($value) + && !_is_json_bool($value) + && looks_like_number($value) + if exists $NUMERIC_TYPES{$type}; + return defined $value + && !ref($value) + && !_is_json_bool($value) + && !looks_like_number($value) + if $type eq 'string'; + + return 1; } sub _value_to_key { @@ -1364,6 +1420,22 @@ sub _validate_extends { "$path/\$extends" ); } + elsif ( + ref($target) ne 'HASH' + || !defined $target->{type} + || ref( $target->{type} ) + || ( $target->{type} ne 'object' + && $target->{type} ne 'tuple' ) + ) + { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "\$extends target '$extends' must resolve to an object or tuple type", + "$path/\$extends" + ); + } + + delete $self->{seen_extends}{$extends}; } sub _has_enabled_extension { diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index eb5cdb3..a371d19 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -186,6 +186,13 @@ private function checkRequiredTopLevelKeywords(array $obj, string $location): vo { if (!isset($obj['$id'])) { $this->addError("Missing required '\$id' keyword at root.", $location, ErrorCodes::SCHEMA_ROOT_MISSING_ID); + } elseif (is_string($obj['$id'])) { + $id = trim($obj['$id']); + if ($id === '') { + $this->addError('\$id must not be empty', '#/$id', ErrorCodes::SCHEMA_KEYWORD_EMPTY); + } elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9+\-.]*:/', $id)) { + $this->addError('\$id must be a URI with a scheme', '#/$id', ErrorCodes::SCHEMA_CONSTRAINT_VALUE_INVALID); + } } // Root schema with 'type' must have 'name' @@ -255,11 +262,8 @@ private function validateSchema( if (isset($schemaObj['name'])) { if (!is_string($schemaObj['name'])) { $this->addError("'name' must be a string.", $path . '/name'); - } else { - $regex = $this->allowDollar ? self::IDENTIFIER_WITH_DOLLAR_REGEX : self::IDENTIFIER_REGEX; - if (!preg_match($regex, $schemaObj['name'])) { - $this->addError("'name' must match the identifier pattern.", $path . '/name'); - } + } elseif (!preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $schemaObj['name'])) { + $this->addError('name must be a valid identifier', $path . '/name', ErrorCodes::SCHEMA_NAME_INVALID); } } @@ -426,6 +430,16 @@ private function validateSchema( if (isset($schemaObj['type']) && is_string($schemaObj['type'])) { if (Types::isCompoundType($schemaObj['type'])) { $this->addError("'enum' cannot be used with compound types.", $path . '/enum'); + } elseif (is_array($enumVal)) { + foreach ($enumVal as $idx => $item) { + if (!$this->enumValueMatchesType($item, $schemaObj['type'])) { + $this->addError( + "enum value at index {$idx} does not match declared type '{$schemaObj['type']}'.", + "{$path}/enum[{$idx}]", + ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH + ); + } + } } } } @@ -1079,7 +1093,13 @@ private function checkTupleSchema(array $obj, string $path): void $this->addError("'tuple' keyword must be an array of strings.", $path . '/tuple'); } else { foreach ($tupleOrder as $idx => $element) { - if (!is_string($element)) { + if (is_array($element) && array_key_exists('$ref', $element)) { + $ref = $element['$ref']; + if (!is_string($ref) || $this->resolveJsonPointer($ref) === null) { + $refText = is_string($ref) ? $ref : ''; + $this->addError("\$ref reference '{$refText}' not found.", "{$path}/tuple[{$idx}]/\$ref", ErrorCodes::SCHEMA_REF_NOT_FOUND); + } + } elseif (!is_string($element)) { $this->addError("Element at index {$idx} in 'tuple' array must be a string.", "{$path}/tuple[{$idx}]"); } elseif (isset($obj['properties']) && is_array($obj['properties']) && !isset($obj['properties'][$element])) { $this->addError("Element '{$element}' in 'tuple' does not correspond to any property in 'properties'.", "{$path}/tuple[{$idx}]"); @@ -1197,7 +1217,9 @@ private function validateExtendsKeyword(mixed $extendsValue, string $path): void $resolved = $this->resolveJsonPointer($ref); if ($resolved === null) { $this->addError("\$extends reference '{$ref}' not found.", $refPath, ErrorCodes::SCHEMA_EXTENDS_NOT_FOUND); - } elseif (is_array($resolved) && isset($resolved['$extends'])) { + } elseif (!is_array($resolved) || !isset($resolved['type']) || !is_string($resolved['type']) || !in_array($resolved['type'], ['object', 'tuple'], true)) { + $this->addError("\$extends target '{$ref}' must resolve to an object or tuple type", $refPath, ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } elseif (isset($resolved['$extends'])) { // Recursively validate the extended schema's $extends $this->validateExtendsKeyword($resolved['$extends'], $refPath); } @@ -1236,6 +1258,16 @@ private function resolveJsonPointer(string $pointer): mixed return $cur; } + private function enumValueMatchesType(mixed $value, string $type): bool + { + return match ($type) { + 'string' => is_string($value), + 'boolean' => is_bool($value), + 'null' => is_null($value), + default => Types::isNumericType($type) ? (is_int($value) || is_float($value)) : true, + }; + } + private function checkOffers(mixed $offers, string $path): void { if (!is_array($offers)) { diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index b3902e5..c1b1594 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -208,7 +208,7 @@ def validate(self, doc, source_text=None): if "$schema" in doc: self._check_is_absolute_uri(doc["$schema"], "$schema", "#/$schema") if "$id" in doc: - self._check_is_absolute_uri(doc["$id"], "$id", "#/$id") + self._check_root_id(doc["$id"], "#/$id") if "$uses" in doc: self._check_uses(doc["$uses"], "#/$uses") if "type" in doc and "$root" in doc: @@ -293,6 +293,19 @@ def _check_is_absolute_uri(self, value, keyword_name, location): if not self.ABSOLUTE_URI_REGEX.search(value): self._err(f"'{keyword_name}' must be an absolute URI.", location) + def _check_root_id(self, value, location): + """ + Validates the root $id keyword. + """ + if not isinstance(value, str): + self._err("'$id' must be a string.", location) + return + if value.strip() == "": + self._err("$id must not be empty", location, ErrorCodes.SCHEMA_KEYWORD_EMPTY) + return + if not re.match(r'^[a-zA-Z][a-zA-Z0-9+\-.]*:', value): + self._err("$id must be a URI with a scheme", location, ErrorCodes.SCHEMA_CONSTRAINT_VALUE_INVALID) + def _rewrite_refs(self, obj, target_path): """ Recursively rewrites $ref pointers in an imported schema to be relative to the target path. @@ -513,8 +526,8 @@ def _validate_schema(self, schema_obj, is_root=False, path="", name_in_namespace if not isinstance(schema_obj["name"], str): self._err(f"'name' must be a string.", path + "/name") else: - if not self.identifier_regex.match(schema_obj["name"]): - self._err(f"'name' must match the identifier pattern.", path + "/name") + if not re.match(r'^[A-Za-z_$][A-Za-z0-9_$]*$', schema_obj["name"]): + self._err("name must be a valid identifier", path + "/name", ErrorCodes.SCHEMA_NAME_INVALID) if "abstract" in schema_obj: if not isinstance(schema_obj["abstract"], bool): self._err(f"'abstract' keyword must be boolean.", path + "/abstract") @@ -636,6 +649,28 @@ def _validate_schema(self, schema_obj, is_root=False, path="", name_in_namespace seen.append(item_str) except (TypeError, ValueError): pass # Can't serialize, skip duplicate check for this item + + type_str = schema_obj.get("type") + if isinstance(type_str, str) and type_str not in self.COMPOUND_TYPES: + is_valid = True + if type_str == "string": + is_valid = isinstance(item, str) + elif type_str in { + "number", "integer", "int8", "uint8", "int16", "uint16", "int32", "uint32", + "int64", "uint64", "int128", "uint128", "float8", "float", "double", "decimal" + }: + is_valid = isinstance(item, (int, float)) and not isinstance(item, bool) + elif type_str == "boolean": + is_valid = isinstance(item, bool) + elif type_str == "null": + is_valid = item is None + + if not is_valid: + self._err( + f"enum value is not valid for type '{type_str}'", + f"{path}/enum[{idx}]", + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH + ) if "type" in schema_obj and isinstance(schema_obj["type"], str): if schema_obj["type"] in self.COMPOUND_TYPES: self._err("'enum' cannot be used with compound types.", path + "/enum") @@ -1229,10 +1264,20 @@ def _check_tuple_schema(self, obj, path): self._err("'tuple' keyword must be an array of strings.", path + "/tuple") else: for idx, element in enumerate(tuple_order): - if not isinstance(element, str): - self._err(f"Element at index {idx} in 'tuple' array must be a string.", path + f"/tuple[{idx}]") - elif "properties" in obj and isinstance(obj["properties"], dict) and element not in obj["properties"]: - self._err(f"Element '{element}' in 'tuple' does not correspond to any property in 'properties'.", path + f"/tuple[{idx}]") + element_path = path + f"/tuple[{idx}]" + if isinstance(element, str): + if "properties" in obj and isinstance(obj["properties"], dict) and element not in obj["properties"]: + self._err(f"Element '{element}' in 'tuple' does not correspond to any property in 'properties'.", element_path) + elif isinstance(element, dict) and "$ref" in element: + ref = element["$ref"] + if not isinstance(ref, str): + self._err("JSON Pointer must be a string.", element_path + "/$ref") + elif not ref.startswith("#"): + self._err("JSON Pointer must start with '#' when referencing the same document.", element_path + "/$ref") + elif self._resolve_json_pointer(ref) is None: + self._err(f"$ref target '{ref}' not found.", element_path + "/$ref", ErrorCodes.SCHEMA_REF_NOT_FOUND) + else: + self._err(f"Element at index {idx} in 'tuple' array must be a string or a $ref object.", element_path) def _check_choice_schema(self, obj, path): """ @@ -1324,7 +1369,13 @@ def _validate_extends_keyword(self, extends_value, path): resolved = self._resolve_json_pointer(ref) if resolved is None: self._err(f"$extends reference '{ref}' not found.", ref_path, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND) - elif isinstance(resolved, dict) and "$extends" in resolved: + elif not isinstance(resolved, dict) or resolved.get("type") not in {"object", "tuple"}: + self._err( + f"$extends target '{ref}' must resolve to an object or tuple type", + ref_path, + ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH + ) + elif "$extends" in resolved: # Recursively validate the extended schema's $extends self._validate_extends_keyword(resolved["$extends"], ref_path) diff --git a/python/tests/test_schema_validator.py b/python/tests/test_schema_validator.py index 24fd8db..83d7102 100644 --- a/python/tests/test_schema_validator.py +++ b/python/tests/test_schema_validator.py @@ -11,6 +11,7 @@ import json import pytest +from json_structure import error_codes from json_structure.schema_validator import validate_json_structure_schema_core # ============================================================================= @@ -1600,7 +1601,80 @@ def test_name_invalid_identifier(): "type": "object" } errors = validate_json_structure_schema_core(schema, json.dumps(schema)) - assert any("name" in err.lower() for err in errors) + assert any(err.code == error_codes.SCHEMA_NAME_INVALID for err in errors) + assert any("name must be a valid identifier" in err.message for err in errors) + + +def test_root_id_empty_rejected(): + schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": " ", + "name": "BadId", + "type": "object" + } + errors = validate_json_structure_schema_core(schema, json.dumps(schema)) + assert any(err.code == error_codes.SCHEMA_KEYWORD_EMPTY for err in errors) + assert any("$id must not be empty" == err.message for err in errors) + + +def test_root_id_requires_uri_scheme(): + schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "example.com/no-scheme", + "name": "BadId", + "type": "object" + } + errors = validate_json_structure_schema_core(schema, json.dumps(schema)) + assert any(err.code == error_codes.SCHEMA_CONSTRAINT_VALUE_INVALID for err in errors) + assert any("$id must be a URI with a scheme" == err.message for err in errors) + + +def test_extends_target_must_be_object_or_tuple(): + schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/bad-extends-target", + "name": "Derived", + "type": "object", + "$extends": "#/definitions/Base", + "definitions": { + "Base": { + "name": "Base", + "type": "string" + } + } + } + errors = validate_json_structure_schema_core(schema, json.dumps(schema)) + assert any(err.code == error_codes.SCHEMA_CONSTRAINT_TYPE_MISMATCH for err in errors) + assert any("$extends target '#/definitions/Base' must resolve to an object or tuple type" == err.message for err in errors) + + +def test_tuple_ref_target_not_found(): + schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/tuple-ref", + "name": "TupleRef", + "type": "tuple", + "properties": { + "name": {"type": "string"} + }, + "tuple": [{"$ref": "#/definitions/Missing"}] + } + errors = validate_json_structure_schema_core(schema, json.dumps(schema)) + assert any(err.code == error_codes.SCHEMA_REF_NOT_FOUND for err in errors) + assert any("$ref target '#/definitions/Missing' not found." == err.message for err in errors) + + +def test_enum_values_must_match_declared_type(): + schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/enum-type-mismatch", + "name": "EnumTypeMismatch", + "type": "boolean", + "enum": [True, "false"] + } + errors = validate_json_structure_schema_core(schema, json.dumps(schema)) + assert any(err.code == error_codes.SCHEMA_CONSTRAINT_TYPE_MISMATCH for err in errors) + assert any("enum value is not valid for type 'boolean'" == err.message for err in errors) def test_ref_not_string(): diff --git a/ruby/lib/jsonstructure/schema_validator.rb b/ruby/lib/jsonstructure/schema_validator.rb index 0d90c59..40b26b7 100644 --- a/ruby/lib/jsonstructure/schema_validator.rb +++ b/ruby/lib/jsonstructure/schema_validator.rb @@ -8,7 +8,10 @@ module JsonStructure # This class is thread-safe. Multiple threads can call validate concurrently. class SchemaValidator UCUM_NUMERIC_TYPES = %w[number integer float double decimal int32 uint32 int64 uint64 int128 uint128].freeze + ENUM_NUMERIC_TYPES = %w[integer int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double decimal].freeze RELATION_CONTAINER_TYPES = %w[object tuple].freeze + IDENTIFIER_PATTERN = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/ + URI_SCHEME_PATTERN = /\A[a-zA-Z][a-zA-Z0-9+\-.]*:/ VALIDATION_KEYWORDS = %w[ pattern format minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minItems maxItems uniqueItems contains minContains maxContains @@ -89,10 +92,14 @@ def validate_extension_keywords(root_schema, node, path, errors) return unless node.is_a?(Hash) type = node['type'] + validate_root_schema_keywords(node, path, errors) if path == '#' validate_validation_extension_gating(root_schema, node, path, errors) validate_ucum_unit_keyword(root_schema, node, type, path, errors) validate_units_keywords(root_schema, node, type, path, errors) validate_relations_keywords(root_schema, node, type, path, errors) + validate_extends_keyword(root_schema, node, path, errors) + validate_tuple_ref_entries(root_schema, node, type, path, errors) + validate_enum_values(type, node, path, errors) node.each do |key, value| child_path = path == '#' ? "#/#{escape_json_pointer(key)}" : "#{path}/#{escape_json_pointer(key)}" @@ -107,6 +114,30 @@ def validate_extension_keywords(root_schema, node, path, errors) end end + def validate_root_schema_keywords(node, path, errors) + validate_root_id_keyword(node, path, errors) + validate_root_name_keyword(node, path, errors) + end + + def validate_root_id_keyword(node, path, errors) + return unless node.key?('$id') + return unless node['$id'].is_a?(String) + + if node['$id'].strip.empty? + add_manual_error(errors, '$id must not be empty', "#{path}/$id", 'SCHEMA_KEYWORD_EMPTY') + elsif node['$id'] !~ URI_SCHEME_PATTERN + add_manual_error(errors, '$id must be a URI with a scheme', "#{path}/$id", 'SCHEMA_CONSTRAINT_VALUE_INVALID') + end + end + + def validate_root_name_keyword(node, path, errors) + return unless node.key?('name') + return unless node['name'].is_a?(String) + return if node['name'].match?(IDENTIFIER_PATTERN) + + add_manual_error(errors, 'name must be a valid identifier', "#{path}/name", 'SCHEMA_NAME_INVALID') + end + def validate_validation_extension_gating(root_schema, node, path, errors) return if extension_enabled?(root_schema, 'JSONStructureValidation') @@ -239,6 +270,81 @@ def validate_relation_scope(scope, relation_path, errors) add_manual_error(errors, "'scope' must be a string or an array of strings.", "#{relation_path}/scope") end + def validate_extends_keyword(root_schema, node, path, errors) + return unless node.key?('$extends') + + refs = normalized_extends_refs(node['$extends'], path) + refs.each do |ref, ref_path| + next unless ref.start_with?('#/') + + resolved = resolve_ref(root_schema, ref) + next unless resolved + next if resolved.is_a?(Hash) && RELATION_CONTAINER_TYPES.include?(resolved['type']) + + add_manual_error(errors, + "$extends target '#{ref}' must resolve to an object or tuple type", + ref_path, + 'SCHEMA_CONSTRAINT_TYPE_MISMATCH') + end + end + + def normalized_extends_refs(extends_value, path) + case extends_value + when String + [[extends_value, "#{path}/$extends"]] + when Array + extends_value.each_with_index.filter_map do |item, index| + [item, "#{path}/$extends[#{index}]"] if item.is_a?(String) + end + else + [] + end + end + + def validate_tuple_ref_entries(root_schema, node, type, path, errors) + return unless type == 'tuple' + return unless node['tuple'].is_a?(Array) + + node['tuple'].each_with_index do |entry, index| + next unless entry.is_a?(Hash) && entry['$ref'].is_a?(String) + + ref = entry['$ref'] + next unless ref.start_with?('#/') + next if resolve_ref(root_schema, ref) + + add_manual_error(errors, "$ref '#{ref}' not found", "#{path}/tuple[#{index}]/$ref", 'SCHEMA_REF_NOT_FOUND') + end + end + + def validate_enum_values(type, node, path, errors) + return unless node['enum'].is_a?(Array) + return unless type.is_a?(String) + + node['enum'].each_with_index do |value, index| + next if enum_value_valid_for_type?(type, value) + + add_manual_error(errors, + "enum value is not valid for type '#{type}'", + "#{path}/enum[#{index}]", + 'SCHEMA_CONSTRAINT_TYPE_MISMATCH') + end + end + + def enum_value_valid_for_type?(type, value) + case type + when 'string' + value.is_a?(String) + when *ENUM_NUMERIC_TYPES + value.is_a?(Numeric) + when 'boolean' + value == true || value == false + when 'null' + value.nil? + else + true + end + end + def validate_relation_ref_object(root_schema, value, relation_path, keyword, errors) keyword_path = "#{relation_path}/#{keyword}" @@ -274,9 +380,9 @@ def escape_json_pointer(segment) segment.to_s.gsub('~', '~0').gsub('/', '~1') end - def add_manual_error(errors, message, path) + def add_manual_error(errors, message, path, code = 0) errors << ValidationError.new( - code: 0, + code: code, severity: FFI::JS_SEVERITY_ERROR, path: path, message: message, @@ -284,9 +390,9 @@ def add_manual_error(errors, message, path) ) end - def add_manual_warning(errors, message, path) + def add_manual_warning(errors, message, path, code = 0) errors << ValidationError.new( - code: 0, + code: code, severity: FFI::JS_SEVERITY_WARNING, path: path, message: message, diff --git a/ruby/spec/schema_validator_spec.rb b/ruby/spec/schema_validator_spec.rb index 57b965c..a672bb6 100644 --- a/ruby/spec/schema_validator_spec.rb +++ b/ruby/spec/schema_validator_spec.rb @@ -183,6 +183,132 @@ end end + describe '.validate with root keyword checks' do + it 'rejects empty $id values' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": " ", + "name": "BadId", + "type": "object" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include(have_attributes(code: 'SCHEMA_KEYWORD_EMPTY', message: '$id must not be empty')) + end + + it 'rejects $id values without a URI scheme' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "example.com/no-scheme", + "name": "BadId", + "type": "object" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include(have_attributes(code: 'SCHEMA_CONSTRAINT_VALUE_INVALID', message: '$id must be a URI with a scheme')) + end + + it 'rejects invalid root names' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/bad-name-id", + "name": "123invalid", + "type": "object" + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include(have_attributes(code: 'SCHEMA_NAME_INVALID', message: 'name must be a valid identifier')) + end + end + + describe '.validate with enum type checks' do + it 'rejects enum values that do not match the declared type' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/enum-type-mismatch", + "name": "EnumTypeMismatch", + "type": "boolean", + "enum": [true, "false"] + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include(have_attributes(code: 'SCHEMA_CONSTRAINT_TYPE_MISMATCH', message: "enum value is not valid for type 'boolean'")) + end + end + + describe '.validate with $extends checks' do + it 'rejects $extends targets that are not object or tuple schemas' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/bad-extends-target", + "name": "Derived", + "type": "object", + "$extends": "#/definitions/Base", + "definitions": { + "Base": { + "name": "Base", + "type": "string" + } + } + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include( + have_attributes( + code: 'SCHEMA_CONSTRAINT_TYPE_MISMATCH', + message: "$extends target '#/definitions/Base' must resolve to an object or tuple type" + ) + ) + end + end + + describe '.validate with tuple ref checks' do + it 'rejects tuple refs that cannot be resolved' do + schema = <<~JSON + { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/tuple-ref", + "name": "TupleRef", + "type": "tuple", + "properties": { + "name": { "type": "string" } + }, + "tuple": [{ "$ref": "#/definitions/Missing" }] + } + JSON + + result = described_class.validate(schema) + + expect(result).to be_invalid + expect(result.errors).to include( + have_attributes( + code: 'SCHEMA_REF_NOT_FOUND', + message: "$ref '#/definitions/Missing' not found" + ) + ) + end + end + describe '.validate with Relations extension' do it 'accepts object identity arrays' do schema = <<~JSON diff --git a/rust/src/error_codes.rs b/rust/src/error_codes.rs index 1346382..95535b3 100644 --- a/rust/src/error_codes.rs +++ b/rust/src/error_codes.rs @@ -82,6 +82,9 @@ pub enum SchemaErrorCode { SchemaPatternInvalid, SchemaFormatInvalid, SchemaConstraintTypeMismatch, + SchemaConstraintValueInvalid, + SchemaKeywordEmpty, + SchemaNameInvalid, SchemaMinimumExceedsMaximum, SchemaMinLengthExceedsMaxLength, SchemaMinLengthNegative, @@ -170,6 +173,9 @@ impl SchemaErrorCode { Self::SchemaPatternInvalid => "SCHEMA_PATTERN_INVALID", Self::SchemaFormatInvalid => "SCHEMA_FORMAT_INVALID", Self::SchemaConstraintTypeMismatch => "SCHEMA_CONSTRAINT_TYPE_MISMATCH", + Self::SchemaConstraintValueInvalid => "SCHEMA_CONSTRAINT_VALUE_INVALID", + Self::SchemaKeywordEmpty => "SCHEMA_KEYWORD_EMPTY", + Self::SchemaNameInvalid => "SCHEMA_NAME_INVALID", Self::SchemaMinimumExceedsMaximum => "SCHEMA_MINIMUM_EXCEEDS_MAXIMUM", Self::SchemaMinLengthExceedsMaxLength => "SCHEMA_MINLENGTH_EXCEEDS_MAXLENGTH", Self::SchemaMinLengthNegative => "SCHEMA_MINLENGTH_NEGATIVE", diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index aeb7cd8..d72f9a6 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -21,7 +21,7 @@ use crate::types::{ /// use json_structure::SchemaValidator; /// /// let validator = SchemaValidator::new(); -/// let result = validator.validate(r#"{"$id": "test", "name": "Test", "type": "string"}"#); +/// let result = validator.validate(r#"{"$id": "https://example.com/test", "name": "Test", "type": "string"}"#); /// assert!(result.is_valid()); /// ``` pub struct SchemaValidator { @@ -299,7 +299,7 @@ impl SchemaValidator { // Validate enum if let Some(enum_val) = obj.get("enum") { - self.validate_enum(enum_val, locator, result, path); + self.validate_enum(enum_val, type_name, locator, result, path); } // Validate composition keywords @@ -328,6 +328,9 @@ impl SchemaValidator { result: &mut ValidationResult, path: &str, ) { + let id_path = format!("{}/$id", path); + let name_path = format!("{}/name", path); + // Root must have $id if !obj.contains_key("$id") { result.add_error(ValidationError::schema_error( @@ -341,9 +344,25 @@ impl SchemaValidator { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaRootMissingId, "$id must be a string", - &format!("{}/$id", path), - locator.get_location(&format!("{}/$id", path)), + &id_path, + locator.get_location(&id_path), )); + } else if let Some(id_str) = id.as_str() { + if id_str.trim().is_empty() { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordEmpty, + "$id must not be empty", + &id_path, + locator.get_location(&id_path), + )); + } else if !Self::has_uri_scheme(id_str) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintValueInvalid, + "$id must be a URI with a scheme", + &id_path, + locator.get_location(&id_path), + )); + } } } @@ -355,6 +374,15 @@ impl SchemaValidator { path, locator.get_location(path), )); + } else if let Some(name) = obj.get("name").and_then(Value::as_str) { + if !Self::is_valid_identifier(name) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaNameInvalid, + "name must be a valid identifier", + &name_path, + locator.get_location(&name_path), + )); + } } // Root must have type OR $root OR definitions OR composition keyword @@ -574,6 +602,46 @@ impl SchemaValidator { Some(current) } + fn has_uri_scheme(value: &str) -> bool { + let Some(colon_pos) = value.find(':') else { + return false; + }; + + let mut chars = value[..colon_pos].chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !first.is_ascii_alphabetic() { + return false; + } + + chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) + } + + fn is_valid_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first.is_ascii_alphabetic() || matches!(first, '_' | '$')) { + return false; + } + + chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + } + + fn enum_value_matches_type(type_name: &str, value: &Value) -> bool { + match type_name { + "string" => matches!(value, Value::String(_)), + "boolean" => matches!(value, Value::Bool(_)), + "null" => matches!(value, Value::Null), + _ if crate::types::is_numeric_type(type_name) => matches!(value, Value::Number(_)), + _ => true, + } + } + /// Validates the type keyword and type-specific requirements. fn validate_type( &self, @@ -1240,7 +1308,7 @@ impl SchemaValidator { fn validate_tuple_type( &self, obj: &serde_json::Map, - _root_schema: &Value, + root_schema: &Value, locator: &JsonSourceLocator, result: &mut ValidationResult, path: &str, @@ -1267,6 +1335,7 @@ impl SchemaValidator { let properties = obj.get("properties").and_then(Value::as_object); for (i, item) in arr.iter().enumerate() { + let item_path = format!("{}/{}", tuple_path, i); match item { Value::String(s) => { // Check property exists @@ -1278,18 +1347,43 @@ impl SchemaValidator { "Tuple element references undefined property: {}", s ), - &format!("{}/{}", tuple_path, i), - locator.get_location(&format!("{}/{}", tuple_path, i)), + &item_path, + locator.get_location(&item_path), + )); + } + } + } + Value::Object(ref_obj) if ref_obj.contains_key("$ref") => { + match ref_obj.get("$ref") { + Some(Value::String(ref_str)) => { + if self.resolve_ref(ref_str, root_schema).is_none() { + let ref_path = format!("{}/$ref", item_path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaRefNotFound, + format!("Reference not found: {}", ref_str), + &ref_path, + locator.get_location(&ref_path), + )); + } + } + Some(_) => { + let ref_path = format!("{}/$ref", item_path); + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaRefNotString, + "$ref must be a string", + &ref_path, + locator.get_location(&ref_path), )); } + None => {} } } _ => { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaTupleInvalidFormat, - "Tuple element must be a string (property name)", - &format!("{}/{}", tuple_path, i), - locator.get_location(&format!("{}/{}", tuple_path, i)), + "Tuple element must be a string (property name) or $ref object", + &item_path, + locator.get_location(&item_path), )); } } @@ -1507,6 +1601,7 @@ impl SchemaValidator { fn validate_enum( &self, enum_val: &Value, + type_name: Option<&str>, locator: &JsonSourceLocator, result: &mut ValidationResult, path: &str, @@ -1525,20 +1620,32 @@ impl SchemaValidator { return; } - // Check for duplicates + // Check for duplicates and declared type compatibility let mut seen = Vec::new(); for (i, item) in arr.iter().enumerate() { + let item_path = format!("{}/{}", enum_path, i); let item_str = item.to_string(); if seen.contains(&item_str) { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaEnumDuplicates, "enum contains duplicate values", - &format!("{}/{}", enum_path, i), - locator.get_location(&format!("{}/{}", enum_path, i)), + &item_path, + locator.get_location(&item_path), )); } else { seen.push(item_str); } + + if let Some(type_str) = type_name { + if !Self::enum_value_matches_type(type_str, item) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + format!("enum value is not valid for type '{}'", type_str), + &item_path, + locator.get_location(&item_path), + )); + } + } } } _ => { @@ -1826,15 +1933,28 @@ impl SchemaValidator { } // Resolve reference - if ref_str.starts_with("#/definitions/") - && self.resolve_ref(&ref_str, root_schema).is_none() - { - result.add_error(ValidationError::schema_error( - SchemaErrorCode::SchemaExtendsNotFound, - format!("$extends reference not found: {}", ref_str), - &ref_path, - locator.get_location(&ref_path), - )); + if ref_str.starts_with("#/definitions/") { + if let Some(resolved) = self.resolve_ref(&ref_str, root_schema) { + let resolved_type = resolved.get("type").and_then(Value::as_str); + if !matches!(resolved_type, Some("object" | "tuple")) { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + format!( + "$extends target '{}' must resolve to an object or tuple type", + ref_str + ), + &ref_path, + locator.get_location(&ref_path), + )); + } + } else { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaExtendsNotFound, + format!("$extends reference not found: {}", ref_str), + &ref_path, + locator.get_location(&ref_path), + )); + } } // External references would be validated here if import is enabled } @@ -2402,4 +2522,111 @@ mod tests { } assert!(result.is_valid(), "Schema with union type should pass"); } + + #[test] + fn test_extends_target_must_be_object_or_tuple() { + let schema = r##"{ + "$id": "https://example.com/schema", + "name": "TestSchema", + "type": "object", + "definitions": { + "Base": { "type": "string" } + }, + "$extends": "#/definitions/Base", + "properties": {} + }"##; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaConstraintTypeMismatch.as_str() + && err.message + == "$extends target '#/definitions/Base' must resolve to an object or tuple type" + })); + } + + #[test] + fn test_tuple_ref_must_exist() { + let schema = r##"{ + "$id": "https://example.com/schema", + "name": "TestSchema", + "type": "tuple", + "properties": { + "first": { "type": "string" } + }, + "tuple": [{ "$ref": "#/definitions/Missing" }] + }"##; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaRefNotFound.as_str() + && err.message == "Reference not found: #/definitions/Missing" + })); + } + + #[test] + fn test_root_id_must_not_be_empty() { + let schema = r#"{ + "$id": " ", + "name": "TestSchema", + "type": "string" + }"#; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaKeywordEmpty.as_str() + && err.message == "$id must not be empty" + })); + } + + #[test] + fn test_root_id_must_have_uri_scheme() { + let schema = r#"{ + "$id": "example.com/schema", + "name": "TestSchema", + "type": "string" + }"#; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaConstraintValueInvalid.as_str() + && err.message == "$id must be a URI with a scheme" + })); + } + + #[test] + fn test_root_name_must_be_valid_identifier() { + let schema = r#"{ + "$id": "https://example.com/schema", + "name": "123Invalid", + "type": "string" + }"#; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaNameInvalid.as_str() + && err.message == "name must be a valid identifier" + })); + } + + #[test] + fn test_enum_values_must_match_type() { + let schema = r#"{ + "$id": "https://example.com/schema", + "name": "TestSchema", + "type": "boolean", + "enum": [true, "false"] + }"#; + + let validator = SchemaValidator::new(); + let result = validator.validate(schema); + assert!(result.all_errors().iter().any(|err| { + err.code == SchemaErrorCode::SchemaConstraintTypeMismatch.as_str() + && err.message == "enum value is not valid for type 'boolean'" + })); + } } diff --git a/swift/Sources/JSONStructure/ErrorCodes.swift b/swift/Sources/JSONStructure/ErrorCodes.swift index 7e0e0fa..3db4fc8 100644 --- a/swift/Sources/JSONStructure/ErrorCodes.swift +++ b/swift/Sources/JSONStructure/ErrorCodes.swift @@ -45,6 +45,10 @@ public let schemaRootMissingName = "SCHEMA_ROOT_MISSING_NAME" public let schemaExtensionKeywordNotEnabled = "SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED" /// Name is not a valid identifier. public let schemaNameInvalid = "SCHEMA_NAME_INVALID" +/// Constraint value has an invalid type for the schema type. +public let schemaConstraintTypeMismatch = "SCHEMA_CONSTRAINT_TYPE_MISMATCH" +/// Constraint value is invalid. +public let schemaConstraintValueInvalid = "SCHEMA_CONSTRAINT_VALUE_INVALID" /// Constraint is not valid for this type. public let schemaConstraintInvalidForType = "SCHEMA_CONSTRAINT_INVALID_FOR_TYPE" /// Minimum cannot be greater than maximum. diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 10ec879..872b2bb 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -2,6 +2,7 @@ // Schema Validator - validates JSON Structure schema documents import Foundation +import CoreFoundation /// Validates JSON Structure schema documents. /// @@ -129,11 +130,20 @@ private final class ValidationEngine { // Root schema must have $id if schema["$id"] == nil { addError("", "Missing required '$id' keyword at root", schemaRootMissingID) + } else if let id = schema["$id"] as? String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + addError("\(path)/$id", "$id must not be empty", schemaKeywordEmpty) + } else if !hasURIScheme(trimmed) { + addError("\(path)/$id", "$id must be a URI with a scheme", schemaConstraintValueInvalid) + } } // Root schema with 'type' must have 'name' if schema["type"] != nil && schema["name"] == nil { addError("", "Root schema with 'type' must have a 'name' property", schemaRootMissingName) + } else if schema["name"] != nil && !isValidIdentifier(schema["name"]) { + addError("\(path)/name", "name must be a valid identifier", schemaNameInvalid) } } @@ -481,16 +491,21 @@ private final class ValidationEngine { let propsMap = schema["properties"] as? [String: Any] for (i, elem) in tupleArr.enumerated() { - guard let name = elem as? String else { - addError("\(path)/tuple[\(i)]", "tuple elements must be strings", schemaKeywordInvalidType) + if let name = elem as? String { + if let props = propsMap { + if props[name] == nil { + addError("\(path)/tuple[\(i)]", "Tuple element '\(name)' not found in properties", schemaRequiredPropertyNotDefined) + } + } continue } - - if let props = propsMap { - if props[name] == nil { - addError("\(path)/tuple[\(i)]", "Tuple element '\(name)' not found in properties", schemaRequiredPropertyNotDefined) - } + + if let refObject = elem as? [String: Any], let ref = refObject["$ref"] { + validateRef(ref, "\(path)/tuple[\(i)]/$ref") + continue } + + addError("\(path)/tuple[\(i)]", "tuple elements must be strings or $ref objects", schemaKeywordInvalidType) } } @@ -536,6 +551,13 @@ private final class ValidationEngine { seen.insert(str) } } + + for item in enumArr { + if !isEnumValueValid(item, forType: typeStr) { + addError("\(path)/enum", "enum value is not valid for type '\(typeStr)'", schemaConstraintTypeMismatch) + break + } + } } } @@ -960,7 +982,10 @@ private final class ValidationEngine { seenExtends.insert(ref) if let resolved = resolveRef(ref) { - if let extendsVal = resolved["$extends"] { + let resolvedType = resolved["type"] as? String + if resolvedType != "object" && resolvedType != "tuple" { + addError(refPath, "$extends target '\(ref)' must resolve to an object or tuple type", schemaConstraintTypeMismatch) + } else if let extendsVal = resolved["$extends"] { validateExtends(extendsVal, refPath) } } else { @@ -1045,6 +1070,57 @@ private final class ValidationEngine { return current as? [String: Any] } + + private func hasURIScheme(_ value: String) -> Bool { + value.range(of: "^[a-zA-Z][a-zA-Z0-9+\\-.]*:", options: .regularExpression) != nil + } + + private func isValidIdentifier(_ value: Any?) -> Bool { + guard let value = value as? String else { + return false + } + + return value.range(of: "^[A-Za-z_$][A-Za-z0-9_$]*$", options: .regularExpression) != nil + } + + private func isEnumValueValid(_ value: Any, forType type: String) -> Bool { + switch type { + case "string": + return value is String + case "boolean": + return isJSONBoolean(value) + case "null": + return value is NSNull + case "integer", "int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64", "float", "double", "decimal": + return isJSONNumber(value) + default: + return true + } + } + + private func isJSONBoolean(_ value: Any) -> Bool { + if value is Bool { + return true + } + + guard let number = value as? NSNumber else { + return false + } + + return CFGetTypeID(number) == CFBooleanGetTypeID() + } + + private func isJSONNumber(_ value: Any) -> Bool { + if value is Bool { + return false + } + + if let number = value as? NSNumber { + return CFGetTypeID(number) != CFBooleanGetTypeID() + } + + return value is Int || value is Double || value is Float || value is Decimal + } /// Serializes any value to a comparable string for uniqueness checks. private func serializeValue(_ value: Any) -> String? { diff --git a/typescript/src/schema-validator.ts b/typescript/src/schema-validator.ts index ffd84c2..5707038 100644 --- a/typescript/src/schema-validator.ts +++ b/typescript/src/schema-validator.ts @@ -39,6 +39,8 @@ const VALIDATION_EXTENSION_KEYWORDS = new Set([ const UNITS_EXTENSION_KEYWORDS = new Set(['unit', 'ucumUnit', 'currency', 'symbols']); const RELATIONS_EXTENSION_KEYWORDS = new Set(['identity', 'relations']); +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/; +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** * Context for a single schema validation operation. @@ -163,11 +165,19 @@ export class SchemaValidator { // Root schema must have $id if (!('$id' in schema)) { this.addError(context, '', "Missing required '$id' keyword at root", ErrorCodes.SCHEMA_ROOT_MISSING_ID); + } else if (typeof schema.$id === 'string') { + if (schema.$id.trim() === '') { + this.addError(context, `${path}/$id`, "Root schema '$id' must not be empty", ErrorCodes.SCHEMA_KEYWORD_EMPTY); + } else if (!URI_SCHEME_PATTERN.test(schema.$id)) { + this.addError(context, `${path}/$id`, "Root schema '$id' must be a URI with a scheme", ErrorCodes.SCHEMA_CONSTRAINT_VALUE_INVALID); + } } // Root schema with 'type' must have 'name' if ('type' in schema && !('name' in schema)) { this.addError(context, '', "Root schema with 'type' must have a 'name' property", ErrorCodes.SCHEMA_ROOT_MISSING_NAME); + } else if (typeof schema.name === 'string' && !IDENTIFIER_PATTERN.test(schema.name)) { + this.addError(context, `${path}/name`, "Root schema 'name' must be a valid identifier", ErrorCodes.SCHEMA_NAME_INVALID); } } @@ -493,11 +503,15 @@ export class SchemaValidator { const props = this.isObject(schema.properties) ? schema.properties : {}; for (let i = 0; i < tuple.length; i++) { - const name = tuple[i]; - if (typeof name !== 'string') { - this.addError(context, `${path}/tuple[${i}]`, 'tuple elements must be strings', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); - } else if (!(name in props)) { - this.addError(context, `${path}/tuple[${i}]`, `Tuple element '${name}' not found in properties`, ErrorCodes.SCHEMA_TUPLE_PROPERTY_NOT_DEFINED); + const entry = tuple[i]; + if (typeof entry === 'string') { + if (!(entry in props)) { + this.addError(context, `${path}/tuple[${i}]`, `Tuple element '${entry}' not found in properties`, ErrorCodes.SCHEMA_TUPLE_PROPERTY_NOT_DEFINED); + } + } else if (this.isObject(entry) && '$ref' in entry) { + this.validateRef(context, entry.$ref, `${path}/tuple[${i}]`); + } else { + this.addError(context, `${path}/tuple[${i}]`, 'tuple elements must be strings or $ref objects', ErrorCodes.SCHEMA_KEYWORD_INVALID_TYPE); } } } @@ -518,7 +532,7 @@ export class SchemaValidator { continue; } - if (!this.hasExtension(schema, 'JSONStructureUnits')) { + if (!this.hasExtensionEnabled(context, schema, 'JSONStructureUnits')) { this.addError( context, `${path}/${keyword}`, @@ -559,7 +573,7 @@ export class SchemaValidator { continue; } - if (!this.hasExtension(schema, 'JSONStructureRelations')) { + if (!this.hasExtensionEnabled(context, schema, 'JSONStructureRelations')) { this.addError( context, `${path}/${keyword}`, @@ -656,6 +670,12 @@ export class SchemaValidator { return Array.isArray(schema.$uses) && schema.$uses.some(value => value === extensionName); } + private hasExtensionEnabled(context: SchemaValidationContext, schema: JsonObject, extensionName: string): boolean { + // Check current schema node first, then fall back to root document $uses + if (this.hasExtension(schema, extensionName)) return true; + return this.hasExtension(context.schema, extensionName); + } + private validateChoiceType(context: SchemaValidationContext, schema: JsonObject, path: string): void { if (!('choices' in schema)) { this.addError(context, path, "Choice type must have 'choices' property", ErrorCodes.SCHEMA_CHOICE_MISSING_CHOICES); @@ -695,6 +715,12 @@ export class SchemaValidator { } seen.add(serialized); } + + for (let i = 0; i < enumVal.length; i++) { + if (!this.isEnumValueValidForPrimitiveType(type, enumVal[i])) { + this.addError(context, `${path}/enum[${i}]`, `enum value is not valid for type '${type}'`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + } } } @@ -712,6 +738,22 @@ export class SchemaValidator { } } + private isEnumValueValidForPrimitiveType(type: string, value: JsonValue): boolean { + if (type === 'string') { + return typeof value === 'string'; + } + if (NUMERIC_TYPES.has(type)) { + return typeof value === 'number'; + } + if (type === 'boolean') { + return typeof value === 'boolean'; + } + if (type === 'null') { + return value === null; + } + return true; + } + private validateStringConstraints(context: SchemaValidationContext, schema: JsonObject, path: string): void { if ('minLength' in schema) { const minLength = schema.minLength; @@ -950,6 +992,10 @@ export class SchemaValidator { if (resolved === null) { this.addError(context, refPath, `$extends reference '${ref}' not found`, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND); } else { + if (resolved.type !== 'object' && resolved.type !== 'tuple') { + this.addError(context, refPath, `$extends target '${ref}' must resolve to an object or tuple type`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } + // Recursively validate the extended schema (which may have its own $extends) if ('$extends' in resolved) { this.validateExtends(context, resolved.$extends, refPath); diff --git a/typescript/tests/schema-validator.test.ts b/typescript/tests/schema-validator.test.ts index a8308eb..4405dad 100644 --- a/typescript/tests/schema-validator.test.ts +++ b/typescript/tests/schema-validator.test.ts @@ -1195,8 +1195,7 @@ describe('SchemaValidator', () => { expect(result.errors.some(e => e.message.includes('$extends reference') && e.message.includes('not found'))).toBe(true); }); - it.skip('should reject $extends referencing a non-object definition', () => { - // TODO: validator does not yet enforce object-only $extends targets. + it('should reject $extends referencing a non-object definition', () => { const schema = { $id: 'urn:example:extends-primitive', name: 'Employee', @@ -1395,8 +1394,7 @@ describe('SchemaValidator', () => { expect(result.errors.some(e => e.path === '#/definitions/Broken/type' && e.message.includes('type must be a string, array, or object with $ref'))).toBe(true); }); - it.skip('should reject tuple entries that reference a type that does not exist', () => { - // TODO: validator does not yet model tuple entries as type references. + it('should reject tuple entries that reference a type that does not exist', () => { const schema = { $id: 'urn:example:tuple-missing-ref', name: 'TupleMissingRef', @@ -1443,8 +1441,7 @@ describe('SchemaValidator', () => { }); describe('extension keywords nested under root $uses', () => { - it.skip('should accept nested ucumUnit when the root enables JSONStructureUnits', () => { - // TODO: validator does not yet inherit root-level $uses into nested schemas. + it('should accept nested ucumUnit when the root enables JSONStructureUnits', () => { const schema = { $id: 'urn:example:nested-ucum-with-root-uses', name: 'MeasurementEnvelope', @@ -1468,8 +1465,7 @@ describe('SchemaValidator', () => { expect(result.isValid).toBe(true); }); - it.skip('should accept nested relations when the root enables JSONStructureRelations', () => { - // TODO: validator does not yet inherit root-level $uses into nested schemas. + it('should accept nested relations when the root enables JSONStructureRelations', () => { const schema = { $id: 'urn:example:nested-relations-with-root-uses', name: 'OrderEnvelope', @@ -1636,8 +1632,7 @@ describe('SchemaValidator', () => { expect(result.errors.some(e => e.message.includes("Missing required '$id' keyword at root"))).toBe(true); }); - it.skip('should reject empty $id', () => { - // TODO: validator does not yet enforce non-empty $id values. + it('should reject empty $id', () => { const schema = { $id: '', name: 'EmptyId', @@ -1649,8 +1644,7 @@ describe('SchemaValidator', () => { expect(result.isValid).toBe(false); }); - it.skip('should reject relative $id values without a scheme', () => { - // TODO: validator does not yet validate $id URI syntax. + it('should reject relative $id values without a scheme', () => { const schema = { $id: 'relative/path', name: 'RelativeId', @@ -1687,8 +1681,7 @@ describe('SchemaValidator', () => { expect(result.errors.some(e => e.message.includes("must have a 'name' property"))).toBe(true); }); - it.skip('should reject names starting with a digit', () => { - // TODO: validator does not yet enforce identifier syntax for name. + it('should reject names starting with a digit', () => { const schema = { $id: 'urn:example:digit-name', name: '1InvalidName', @@ -1700,8 +1693,7 @@ describe('SchemaValidator', () => { expect(result.isValid).toBe(false); }); - it.skip('should reject names containing spaces', () => { - // TODO: validator does not yet enforce identifier syntax for name. + it('should reject names containing spaces', () => { const schema = { $id: 'urn:example:space-name', name: 'Invalid Name', @@ -1742,8 +1734,7 @@ describe('SchemaValidator', () => { expect(result.errors).toHaveLength(0); }); - it.skip('should reject mixed enum types when the schema type is string', () => { - // TODO: validator does not yet enforce enum element types against the declared type. + it('should reject mixed enum types when the schema type is string', () => { const schema = { $id: 'urn:example:enum-mixed-string', name: 'MixedStringEnum', @@ -1915,3 +1906,4 @@ describe('SchemaValidator', () => { }); }); }); + From a93680ba014d95b8e1b1534858c782979ebaff4e Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 16:55:31 +0200 Subject: [PATCH 18/20] fix: relax \ target check for composition schemas Only enforce object/tuple type requirement when the target has an explicit 'type' field. Schemas using composition (allOf/oneOf/anyOf) without 'type' are valid \ targets. Also fix Perl SCHEMA_CONSTRAINT_VALUE_INVALID missing constant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/src/schema_validator.c | 4 ++-- dotnet/src/JsonStructure/Validation/SchemaValidator.cs | 2 +- go/schema_validator.go | 4 ++-- .../org/json_structure/validation/SchemaValidator.java | 3 +-- perl/lib/JSON/Structure/ErrorCodes.pm | 7 +++++++ perl/lib/JSON/Structure/SchemaValidator.pm | 6 +++--- php/src/JsonStructure/SchemaValidator.php | 2 +- python/src/json_structure/schema_validator.py | 2 +- ruby/lib/jsonstructure/schema_validator.rb | 2 +- rust/src/schema_validator.rs | 2 +- swift/Sources/JSONStructure/SchemaValidator.swift | 2 +- typescript/src/schema-validator.ts | 2 +- 12 files changed, 22 insertions(+), 16 deletions(-) diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index 2b61837..4f01358 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -1550,8 +1550,8 @@ static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* exten } const cJSON* type = cJSON_GetObjectItemCaseSensitive(target, "type"); - if (!cJSON_IsString(type) || - (strcmp(type->valuestring, "object") != 0 && strcmp(type->valuestring, "tuple") != 0)) { + if (cJSON_IsString(type) && + strcmp(type->valuestring, "object") != 0 && strcmp(type->valuestring, "tuple") != 0) { char msg[256]; snprintf(msg, sizeof(msg), "$extends target '%s' must resolve to an object or tuple type", extends_node->valuestring); add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, msg); diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 7dc9d03..20af36a 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -886,7 +886,7 @@ private void ValidateExtendsKeyword(JsonNode? value, string path, ValidationResu ? GetTypeString(resolvedTypeValue) : null; - if (resolvedType is not ("object" or "tuple")) + if (resolvedType is not null and not ("object" or "tuple")) { AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must resolve to an object or tuple type", refPath); } diff --git a/go/schema_validator.go b/go/schema_validator.go index 2c28898..73bdac4 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -1048,8 +1048,8 @@ func (ctx *schemaValidationContext) validateExtends(extendsVal interface{}, path if resolved == nil { ctx.addError(refPath, fmt.Sprintf("$extends reference '%s' not found", ref), SchemaExtendsNotFound) } else { - resolvedType, _ := resolved["type"].(string) - if resolvedType != "object" && resolvedType != "tuple" { + resolvedType, hasType := resolved["type"].(string) + if hasType && resolvedType != "object" && resolvedType != "tuple" { ctx.addError(refPath, fmt.Sprintf("$extends target '%s' must resolve to an object or tuple type", ref), SchemaConstraintTypeMismatch) } else if extendsVal, hasExtends := resolved["$extends"]; hasExtends { // Recursively validate the extended schema's $extends diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index b61b605..4e59c00 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -799,8 +799,7 @@ private void validateExtendsRef(String refStr, String path, ValidationResult res } else { JsonNode baseType = baseSchema.get("type"); String baseTypeStr = baseType != null && baseType.isTextual() ? baseType.asText() : null; - boolean isNamespaceMap = "map".equals(baseTypeStr) && refStr.endsWith("/Namespace"); - if (!"object".equals(baseTypeStr) && !"tuple".equals(baseTypeStr) && !isNamespaceMap) { + if (baseTypeStr != null && !"object".equals(baseTypeStr) && !"tuple".equals(baseTypeStr)) { addError(result, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, "$extends target '" + refStr + "' must resolve to an object or tuple type", refPath); } else if (baseSchema.isObject() && baseSchema.has("$extends")) { diff --git a/perl/lib/JSON/Structure/ErrorCodes.pm b/perl/lib/JSON/Structure/ErrorCodes.pm index d5c5fc9..c691eaf 100644 --- a/perl/lib/JSON/Structure/ErrorCodes.pm +++ b/perl/lib/JSON/Structure/ErrorCodes.pm @@ -86,6 +86,8 @@ use constant SCHEMA_MULTIPLE_OF_NOT_POSITIVE => 'SCHEMA_MULTIPLE_OF_NOT_POSITIVE'; use constant SCHEMA_CONSTRAINT_TYPE_MISMATCH => 'SCHEMA_CONSTRAINT_TYPE_MISMATCH'; +use constant SCHEMA_CONSTRAINT_VALUE_INVALID => + 'SCHEMA_CONSTRAINT_VALUE_INVALID'; use constant SCHEMA_CIRCULAR_REF => 'SCHEMA_CIRCULAR_REF'; # Instance Validation Errors @@ -248,6 +250,7 @@ our @EXPORT_OK = qw( SCHEMA_MIN_LENGTH_NEGATIVE SCHEMA_MULTIPLE_OF_NOT_POSITIVE SCHEMA_CONSTRAINT_TYPE_MISMATCH + SCHEMA_CONSTRAINT_VALUE_INVALID SCHEMA_CIRCULAR_REF INSTANCE_ROOT_UNRESOLVED @@ -406,6 +409,10 @@ q{$ref is only permitted inside the 'type' attribute. Use { "type": { "$ref": ". SCHEMA_UNIQUE_ITEMS_NOT_BOOLEAN() => 'uniqueItems must be a boolean', SCHEMA_ITEMS_INVALID_FOR_TUPLE() => 'items must be a boolean or schema for tuple type', + SCHEMA_CONSTRAINT_TYPE_MISMATCH() => + 'Constraint type mismatch', + SCHEMA_CONSTRAINT_VALUE_INVALID() => + 'Constraint value is invalid', INSTANCE_ROOT_UNRESOLVED() => 'Unable to resolve $root reference: {refPath}', diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 3a720b7..933084f 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -1422,9 +1422,9 @@ sub _validate_extends { } elsif ( ref($target) ne 'HASH' - || !defined $target->{type} - || ref( $target->{type} ) - || ( $target->{type} ne 'object' + || ( defined $target->{type} + && !ref( $target->{type} ) + && $target->{type} ne 'object' && $target->{type} ne 'tuple' ) ) { diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index a371d19..3dfcc17 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -1217,7 +1217,7 @@ private function validateExtendsKeyword(mixed $extendsValue, string $path): void $resolved = $this->resolveJsonPointer($ref); if ($resolved === null) { $this->addError("\$extends reference '{$ref}' not found.", $refPath, ErrorCodes::SCHEMA_EXTENDS_NOT_FOUND); - } elseif (!is_array($resolved) || !isset($resolved['type']) || !is_string($resolved['type']) || !in_array($resolved['type'], ['object', 'tuple'], true)) { + } elseif (!is_array($resolved) || (isset($resolved['type']) && is_string($resolved['type']) && !in_array($resolved['type'], ['object', 'tuple'], true))) { $this->addError("\$extends target '{$ref}' must resolve to an object or tuple type", $refPath, ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); } elseif (isset($resolved['$extends'])) { // Recursively validate the extended schema's $extends diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index c1b1594..155ebc1 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -1369,7 +1369,7 @@ def _validate_extends_keyword(self, extends_value, path): resolved = self._resolve_json_pointer(ref) if resolved is None: self._err(f"$extends reference '{ref}' not found.", ref_path, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND) - elif not isinstance(resolved, dict) or resolved.get("type") not in {"object", "tuple"}: + elif not isinstance(resolved, dict) or ("type" in resolved and resolved.get("type") not in {"object", "tuple"}): self._err( f"$extends target '{ref}' must resolve to an object or tuple type", ref_path, diff --git a/ruby/lib/jsonstructure/schema_validator.rb b/ruby/lib/jsonstructure/schema_validator.rb index 40b26b7..700b429 100644 --- a/ruby/lib/jsonstructure/schema_validator.rb +++ b/ruby/lib/jsonstructure/schema_validator.rb @@ -279,7 +279,7 @@ def validate_extends_keyword(root_schema, node, path, errors) resolved = resolve_ref(root_schema, ref) next unless resolved - next if resolved.is_a?(Hash) && RELATION_CONTAINER_TYPES.include?(resolved['type']) + next if resolved.is_a?(Hash) && (!resolved.key?('type') || RELATION_CONTAINER_TYPES.include?(resolved['type'])) add_manual_error(errors, "$extends target '#{ref}' must resolve to an object or tuple type", diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index d72f9a6..8f07cc9 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -1936,7 +1936,7 @@ impl SchemaValidator { if ref_str.starts_with("#/definitions/") { if let Some(resolved) = self.resolve_ref(&ref_str, root_schema) { let resolved_type = resolved.get("type").and_then(Value::as_str); - if !matches!(resolved_type, Some("object" | "tuple")) { + if resolved_type.is_some() && !matches!(resolved_type, Some("object" | "tuple")) { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, format!( diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 872b2bb..70a5849 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -983,7 +983,7 @@ private final class ValidationEngine { if let resolved = resolveRef(ref) { let resolvedType = resolved["type"] as? String - if resolvedType != "object" && resolvedType != "tuple" { + if resolvedType != nil && resolvedType != "object" && resolvedType != "tuple" { addError(refPath, "$extends target '\(ref)' must resolve to an object or tuple type", schemaConstraintTypeMismatch) } else if let extendsVal = resolved["$extends"] { validateExtends(extendsVal, refPath) diff --git a/typescript/src/schema-validator.ts b/typescript/src/schema-validator.ts index 5707038..d0b4441 100644 --- a/typescript/src/schema-validator.ts +++ b/typescript/src/schema-validator.ts @@ -992,7 +992,7 @@ export class SchemaValidator { if (resolved === null) { this.addError(context, refPath, `$extends reference '${ref}' not found`, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND); } else { - if (resolved.type !== 'object' && resolved.type !== 'tuple') { + if ('type' in resolved && resolved.type !== 'object' && resolved.type !== 'tuple') { this.addError(context, refPath, `$extends target '${ref}' must resolve to an object or tuple type`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); } From a6c280cb203bbe80ef85731eacb60a0868b11a6c Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 17:04:32 +0200 Subject: [PATCH 19/20] fix: allow to target map/array/set/choice types The meta-schemas use \ with map types (e.g., ImportAddIn extends Namespace which is type:map). Broaden check to reject only primitive types, not all non-object/tuple types. Also add SCHEMA_CONSTRAINT_VALUE_INVALID to Perl ErrorCodes exports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/src/schema_validator.c | 6 ++++-- c/tests/test_schema_validator.c | 2 +- dotnet/src/JsonStructure/Validation/SchemaValidator.cs | 6 +++--- go/schema_validator.go | 4 ++-- .../org/json_structure/validation/SchemaValidator.java | 4 ++-- .../json_structure/validation/SchemaValidatorTests.java | 2 +- perl/lib/JSON/Structure/SchemaValidator.pm | 8 ++++++-- php/src/JsonStructure/SchemaValidator.php | 4 ++-- python/src/json_structure/schema_validator.py | 4 ++-- python/tests/test_schema_validator.py | 2 +- ruby/lib/jsonstructure/schema_validator.rb | 4 ++-- ruby/spec/schema_validator_spec.rb | 2 +- rust/src/schema_validator.rs | 6 +++--- swift/Sources/JSONStructure/SchemaValidator.swift | 5 +++-- typescript/src/schema-validator.ts | 5 +++-- 15 files changed, 36 insertions(+), 28 deletions(-) diff --git a/c/src/schema_validator.c b/c/src/schema_validator.c index 4f01358..4c3c1c7 100644 --- a/c/src/schema_validator.c +++ b/c/src/schema_validator.c @@ -1551,9 +1551,11 @@ static bool validate_extends_keyword(validate_context_t* ctx, const cJSON* exten const cJSON* type = cJSON_GetObjectItemCaseSensitive(target, "type"); if (cJSON_IsString(type) && - strcmp(type->valuestring, "object") != 0 && strcmp(type->valuestring, "tuple") != 0) { + strcmp(type->valuestring, "object") != 0 && strcmp(type->valuestring, "tuple") != 0 && + strcmp(type->valuestring, "map") != 0 && strcmp(type->valuestring, "array") != 0 && + strcmp(type->valuestring, "set") != 0 && strcmp(type->valuestring, "choice") != 0) { char msg[256]; - snprintf(msg, sizeof(msg), "$extends target '%s' must resolve to an object or tuple type", extends_node->valuestring); + snprintf(msg, sizeof(msg), "$extends target '%s' must not resolve to a primitive type", extends_node->valuestring); add_error(ctx, JS_SCHEMA_CONSTRAINT_TYPE_MISMATCH, msg); valid = false; } diff --git a/c/tests/test_schema_validator.c b/c/tests/test_schema_validator.c index 829eb6e..d638d0f 100644 --- a/c/tests/test_schema_validator.c +++ b/c/tests/test_schema_validator.c @@ -386,7 +386,7 @@ TEST(invalid_extends_target_type) { js_result_init(&result); bool valid = js_validate_schema(schema, &result); - int ok = !valid && result_has_message(&result, "$extends target '#/definitions/Base' must resolve to an object or tuple type"); + int ok = !valid && result_has_message(&result, "$extends target '#/definitions/Base' must not resolve to a primitive type"); js_result_cleanup(&result); return ok ? 0 : 1; diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 20af36a..5ff69cb 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -878,7 +878,7 @@ private void ValidateExtendsKeyword(JsonNode? value, string path, ValidationResu } else if (resolved is not JsonObject resolvedObj) { - AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must resolve to an object or tuple type", refPath); + AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must not resolve to a primitive type", refPath); } else { @@ -886,9 +886,9 @@ private void ValidateExtendsKeyword(JsonNode? value, string path, ValidationResu ? GetTypeString(resolvedTypeValue) : null; - if (resolvedType is not null and not ("object" or "tuple")) + if (resolvedType is not null and not ("object" or "tuple" or "map" or "array" or "set" or "choice")) { - AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must resolve to an object or tuple type", refPath); + AddError(result, ErrorCodes.SchemaConstraintTypeMismatch, $"$extends target '{refStr}' must not resolve to a primitive type", refPath); } else if (resolvedObj.TryGetPropertyValue("$extends", out var nestedExtends)) { diff --git a/go/schema_validator.go b/go/schema_validator.go index 73bdac4..63d1295 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -1049,8 +1049,8 @@ func (ctx *schemaValidationContext) validateExtends(extendsVal interface{}, path ctx.addError(refPath, fmt.Sprintf("$extends reference '%s' not found", ref), SchemaExtendsNotFound) } else { resolvedType, hasType := resolved["type"].(string) - if hasType && resolvedType != "object" && resolvedType != "tuple" { - ctx.addError(refPath, fmt.Sprintf("$extends target '%s' must resolve to an object or tuple type", ref), SchemaConstraintTypeMismatch) + if hasType && resolvedType != "object" && resolvedType != "tuple" && resolvedType != "map" && resolvedType != "array" && resolvedType != "set" && resolvedType != "choice" { + ctx.addError(refPath, fmt.Sprintf("$extends target '%s' must not resolve to a primitive type", ref), SchemaConstraintTypeMismatch) } else if extendsVal, hasExtends := resolved["$extends"]; hasExtends { // Recursively validate the extended schema's $extends ctx.validateExtends(extendsVal, refPath) diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index 4e59c00..df81642 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -799,9 +799,9 @@ private void validateExtendsRef(String refStr, String path, ValidationResult res } else { JsonNode baseType = baseSchema.get("type"); String baseTypeStr = baseType != null && baseType.isTextual() ? baseType.asText() : null; - if (baseTypeStr != null && !"object".equals(baseTypeStr) && !"tuple".equals(baseTypeStr)) { + if (baseTypeStr != null && !"object".equals(baseTypeStr) && !"tuple".equals(baseTypeStr) && !"map".equals(baseTypeStr) && !"array".equals(baseTypeStr) && !"set".equals(baseTypeStr) && !"choice".equals(baseTypeStr)) { addError(result, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH, - "$extends target '" + refStr + "' must resolve to an object or tuple type", refPath); + "$extends target '" + refStr + "' must not resolve to a primitive type", refPath); } else if (baseSchema.isObject() && baseSchema.has("$extends")) { validateExtendsKeyword(baseSchema.get("$extends"), refStr, result); } diff --git a/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java b/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java index c8fca85..fd0d6ac 100644 --- a/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java +++ b/java/src/test/java/org/json_structure/validation/SchemaValidatorTests.java @@ -657,7 +657,7 @@ void extendsTargetMustBeObjectOrTuple() { assertThat(result.isValid()).isFalse(); assertThat(result.getErrors()).anyMatch(e -> ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH.equals(e.getCode()) && - e.getMessage().equals("$extends target '#/definitions/Base' must resolve to an object or tuple type")); + e.getMessage().equals("$extends target '#/definitions/Base' must not resolve to a primitive type")); } @Test diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 933084f..1483e1f 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -1425,12 +1425,16 @@ sub _validate_extends { || ( defined $target->{type} && !ref( $target->{type} ) && $target->{type} ne 'object' - && $target->{type} ne 'tuple' ) + && $target->{type} ne 'tuple' + && $target->{type} ne 'map' + && $target->{type} ne 'array' + && $target->{type} ne 'set' + && $target->{type} ne 'choice' ) ) { $self->_add_error( SCHEMA_CONSTRAINT_TYPE_MISMATCH, - "\$extends target '$extends' must resolve to an object or tuple type", + "\$extends target '$extends' must not resolve to a primitive type", "$path/\$extends" ); } diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index 3dfcc17..60a3829 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -1217,8 +1217,8 @@ private function validateExtendsKeyword(mixed $extendsValue, string $path): void $resolved = $this->resolveJsonPointer($ref); if ($resolved === null) { $this->addError("\$extends reference '{$ref}' not found.", $refPath, ErrorCodes::SCHEMA_EXTENDS_NOT_FOUND); - } elseif (!is_array($resolved) || (isset($resolved['type']) && is_string($resolved['type']) && !in_array($resolved['type'], ['object', 'tuple'], true))) { - $this->addError("\$extends target '{$ref}' must resolve to an object or tuple type", $refPath, ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); + } elseif (!is_array($resolved) || (isset($resolved['type']) && is_string($resolved['type']) && !in_array($resolved['type'], ['object', 'tuple', 'map', 'array', 'set', 'choice'], true))) { + $this->addError("\$extends target '{$ref}' must not resolve to a primitive type", $refPath, ErrorCodes::SCHEMA_CONSTRAINT_TYPE_MISMATCH); } elseif (isset($resolved['$extends'])) { // Recursively validate the extended schema's $extends $this->validateExtendsKeyword($resolved['$extends'], $refPath); diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index 155ebc1..c9e077d 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -1369,9 +1369,9 @@ def _validate_extends_keyword(self, extends_value, path): resolved = self._resolve_json_pointer(ref) if resolved is None: self._err(f"$extends reference '{ref}' not found.", ref_path, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND) - elif not isinstance(resolved, dict) or ("type" in resolved and resolved.get("type") not in {"object", "tuple"}): + elif not isinstance(resolved, dict) or ("type" in resolved and resolved.get("type") not in {"object", "tuple", "map", "array", "set", "choice"}): self._err( - f"$extends target '{ref}' must resolve to an object or tuple type", + f"$extends target '{ref}' must not resolve to a primitive type", ref_path, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH ) diff --git a/python/tests/test_schema_validator.py b/python/tests/test_schema_validator.py index 83d7102..8a48f3b 100644 --- a/python/tests/test_schema_validator.py +++ b/python/tests/test_schema_validator.py @@ -1645,7 +1645,7 @@ def test_extends_target_must_be_object_or_tuple(): } errors = validate_json_structure_schema_core(schema, json.dumps(schema)) assert any(err.code == error_codes.SCHEMA_CONSTRAINT_TYPE_MISMATCH for err in errors) - assert any("$extends target '#/definitions/Base' must resolve to an object or tuple type" == err.message for err in errors) + assert any("$extends target '#/definitions/Base' must not resolve to a primitive type" == err.message for err in errors) def test_tuple_ref_target_not_found(): diff --git a/ruby/lib/jsonstructure/schema_validator.rb b/ruby/lib/jsonstructure/schema_validator.rb index 700b429..a68ce91 100644 --- a/ruby/lib/jsonstructure/schema_validator.rb +++ b/ruby/lib/jsonstructure/schema_validator.rb @@ -279,10 +279,10 @@ def validate_extends_keyword(root_schema, node, path, errors) resolved = resolve_ref(root_schema, ref) next unless resolved - next if resolved.is_a?(Hash) && (!resolved.key?('type') || RELATION_CONTAINER_TYPES.include?(resolved['type'])) + next if resolved.is_a?(Hash) && (!resolved.key?('type') || %w[object tuple map array set choice].include?(resolved['type'])) add_manual_error(errors, - "$extends target '#{ref}' must resolve to an object or tuple type", + "$extends target '#{ref}' must not resolve to a primitive type", ref_path, 'SCHEMA_CONSTRAINT_TYPE_MISMATCH') end diff --git a/ruby/spec/schema_validator_spec.rb b/ruby/spec/schema_validator_spec.rb index a672bb6..0e33300 100644 --- a/ruby/spec/schema_validator_spec.rb +++ b/ruby/spec/schema_validator_spec.rb @@ -276,7 +276,7 @@ expect(result.errors).to include( have_attributes( code: 'SCHEMA_CONSTRAINT_TYPE_MISMATCH', - message: "$extends target '#/definitions/Base' must resolve to an object or tuple type" + message: "$extends target '#/definitions/Base' must not resolve to a primitive type" ) ) end diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index 8f07cc9..e2c47d9 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -1936,11 +1936,11 @@ impl SchemaValidator { if ref_str.starts_with("#/definitions/") { if let Some(resolved) = self.resolve_ref(&ref_str, root_schema) { let resolved_type = resolved.get("type").and_then(Value::as_str); - if resolved_type.is_some() && !matches!(resolved_type, Some("object" | "tuple")) { + if resolved_type.is_some() && !matches!(resolved_type, Some("object" | "tuple" | "map" | "array" | "set" | "choice")) { result.add_error(ValidationError::schema_error( SchemaErrorCode::SchemaConstraintTypeMismatch, format!( - "$extends target '{}' must resolve to an object or tuple type", + "$extends target '{}' must not resolve to a primitive type", ref_str ), &ref_path, @@ -2541,7 +2541,7 @@ mod tests { assert!(result.all_errors().iter().any(|err| { err.code == SchemaErrorCode::SchemaConstraintTypeMismatch.as_str() && err.message - == "$extends target '#/definitions/Base' must resolve to an object or tuple type" + == "$extends target '#/definitions/Base' must not resolve to a primitive type" })); } diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 70a5849..8ed85dc 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -983,8 +983,9 @@ private final class ValidationEngine { if let resolved = resolveRef(ref) { let resolvedType = resolved["type"] as? String - if resolvedType != nil && resolvedType != "object" && resolvedType != "tuple" { - addError(refPath, "$extends target '\(ref)' must resolve to an object or tuple type", schemaConstraintTypeMismatch) + let allowedExtendTypes: Set = ["object", "tuple", "map", "array", "set", "choice"] + if resolvedType != nil && !allowedExtendTypes.contains(resolvedType!) { + addError(refPath, "$extends target '\(ref)' must not resolve to a primitive type", schemaConstraintTypeMismatch) } else if let extendsVal = resolved["$extends"] { validateExtends(extendsVal, refPath) } diff --git a/typescript/src/schema-validator.ts b/typescript/src/schema-validator.ts index d0b4441..99e8207 100644 --- a/typescript/src/schema-validator.ts +++ b/typescript/src/schema-validator.ts @@ -992,8 +992,9 @@ export class SchemaValidator { if (resolved === null) { this.addError(context, refPath, `$extends reference '${ref}' not found`, ErrorCodes.SCHEMA_EXTENDS_NOT_FOUND); } else { - if ('type' in resolved && resolved.type !== 'object' && resolved.type !== 'tuple') { - this.addError(context, refPath, `$extends target '${ref}' must resolve to an object or tuple type`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); + const PRIMITIVE_TYPES_FOR_EXTENDS = new Set(['string', 'boolean', 'null', 'integer', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64', 'float', 'double', 'decimal', 'binary', 'uri', 'uri-template', 'datetime', 'date', 'time', 'duration', 'uuid', 'ipv4', 'ipv6']); + if ('type' in resolved && typeof resolved.type === 'string' && PRIMITIVE_TYPES_FOR_EXTENDS.has(resolved.type)) { + this.addError(context, refPath, `$extends target '${ref}' must not resolve to a primitive type`, ErrorCodes.SCHEMA_CONSTRAINT_TYPE_MISMATCH); } // Recursively validate the extended schema (which may have its own $extends) From e60338b3ac9afe5bbbdd3a9f5b37db1949e1a0fb Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 17:09:10 +0200 Subject: [PATCH 20/20] fix(perl): add recursive \ validation for circular detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without following the \ chain recursively, circular references spanning multiple hops (A→B→C→A) would not be detected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- perl/lib/JSON/Structure/SchemaValidator.pm | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 1483e1f..a353443 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -1438,6 +1438,10 @@ sub _validate_extends { "$path/\$extends" ); } + elsif ( ref($target) eq 'HASH' && defined $target->{'$extends'} ) { + # Recursively validate the extended schema's $extends + $self->_validate_extends( $target->{'$extends'}, "$path/\$extends" ); + } delete $self->{seen_extends}{$extends}; }