Skip to content

Commit 203ebca

Browse files
authored
[JSON Validator] Tuple validation, multi-type enum checks (#213)
This PR addresses two related bugs in the schema validator that caused it to throw fatal errors (`UnsupportedSchemaException` or a `TypeError`) when it should have gracefully returned a `ValidationError`. ### The Problem The validator would crash when attempting to validate data against a schema that used either of the following valid JSON Schema constructs: 1. **Tuple Validation:** When an array's `items` keyword was an array of schemas (e.g., `{"type": "array", "items": [{"type": "string"}, {"type": "object"}]}`), the validator would throw an `UnsupportedSchemaException` instead of validating the data. 2. **Multi-type Enums:** When a schema used an array for the `type` keyword along with an `enum` (e.g., `{"type": ["string", "number"]}`), the validator's internal integrity check would cause a fatal `TypeError`. This meant that schemas that are perfectly valid according to the JSON Schema spec were not supported and would break the validation process entirely. ### Root Cause Analysis 1. The `validate_array()` method was only implemented to handle "list validation" (where `items` is a single schema object). It lacked the logic to handle "tuple validation," causing it to misinterpret the array of schemas and throw an exception. 2. The schema integrity check inside `validate_type()` was incorrectly calling `type_matches()`—which only accepts a string for the type—instead of the `type_matches_any()` method, which is designed to handle an array of types. ### The Solution This PR implements two fixes to make the validator more compliant with the JSON Schema spec: 1. **Added Tuple Validation Logic:** The `validate_array()` method now detects when the `items` keyword is an array (a tuple) and applies the correct validation logic, iterating through the data and schema arrays in lockstep. 2. **Corrected the Enum Integrity Check:** The check in `validate_type()` has been updated to use the `type_matches_any()` method, allowing it to correctly validate schemas that use multiple types. ### How to Verify I have added a new unit test, `testItReturnsValidationErrorForInvalidTupleData()`, which uses a schema with tuple validation. * **Before this fix:** Running this test causes the validator to throw a fatal `UnsupportedSchemaException`. * **After this fix:** The test now passes, as the validator correctly identifies the invalid data and returns a `ValidationError` object as expected.
1 parent 6542f2a commit 203ebca

File tree

2 files changed

+152
-5
lines changed

2 files changed

+152
-5
lines changed

components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,28 @@ public static function arrayProvider(): array {
312312
false,
313313
'Missing required field: id.',
314314
],
315+
'tuple validation: valid with different schemas' => [
316+
[ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ],
317+
[ 'a string', 123 ],
318+
true,
319+
],
320+
'tuple validation: valid with identical schemas' => [
321+
[ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'string' ] ] ],
322+
[ 'hello', 'world' ],
323+
true,
324+
],
325+
'tuple validation: invalid item type' => [
326+
[ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ],
327+
[ 'a string', 'another string' ], // Second item should be an integer
328+
false,
329+
'Expected type "integer" but got type "string".',
330+
],
331+
'tuple validation: too many items' => [
332+
[ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ],
333+
[ 'a string', 123, 'extra item' ], // Contains one too many items
334+
false,
335+
'Tuple validation failed: expected at most 2 items but got 3.',
336+
],
315337
];
316338
}
317339

@@ -1643,4 +1665,77 @@ private function assertIsValid( $result, bool $shouldBeValid = true ) {
16431665
private function assertIsInvalid( $result ) {
16441666
$this->assertInstanceOf( ValidationError::class, $result );
16451667
}
1668+
1669+
public function testItReturnsValidationErrorForInvalidTupleData() {
1670+
$schema_path = __DIR__ . '/../../../Versions/Version2/json-schema/schema-v2.json';
1671+
$schema = json_decode( file_get_contents( $schema_path ), true );
1672+
$this->assertIsArray( $schema, 'Failed to parse schema file: ' . $schema_path );
1673+
1674+
$invalid_blueprint_json = '{
1675+
"version": 2,
1676+
"blueprintMeta": {
1677+
"name": "Invalid Blueprint - Wrong Post Type Format",
1678+
"description": "This blueprint has invalid post type definitions"
1679+
},
1680+
"postTypes": {
1681+
"book": {
1682+
"label": 123,
1683+
"public": "not-a-boolean",
1684+
"hierarchical": "wrong-type",
1685+
"show_in_menu": {},
1686+
"capability_type": [
1687+
"one",
1688+
"two",
1689+
"three"
1690+
],
1691+
"template": [
1692+
"invalid-not-an-array",
1693+
[
1694+
"valid-item-1"
1695+
],
1696+
[
1697+
"valid-item-2",
1698+
{}
1699+
],
1700+
[
1701+
123,
1702+
"not-string-first-element"
1703+
]
1704+
],
1705+
"supports": [
1706+
"title",
1707+
123,
1708+
true
1709+
]
1710+
}
1711+
},
1712+
"content": [
1713+
{
1714+
"type": "posts",
1715+
"source": [
1716+
{
1717+
"post_title": "Test Post",
1718+
"post_content": "This is a test post."
1719+
}
1720+
]
1721+
}
1722+
]
1723+
}';
1724+
$invalid_blueprint = json_decode( $invalid_blueprint_json );
1725+
1726+
$validator = new HumanFriendlySchemaValidator( $schema );
1727+
try {
1728+
$error = $validator->validate( $invalid_blueprint );
1729+
} catch ( UnsupportedSchemaException $e ) {
1730+
$this->fail(
1731+
'The validator threw an UnsupportedSchemaException when it should have returned a ValidationError. Exception message: ' . $e->getMessage()
1732+
);
1733+
}
1734+
1735+
$this->assertInstanceOf(
1736+
ValidationError::class,
1737+
$error,
1738+
'The validator should return a ValidationError, not throw an UnsupportedSchemaException exception or pass.'
1739+
);
1740+
}
16461741
}

components/Blueprints/Validator/class-humanfriendlyschemavalidator.php

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ private function validate_type( array $path, $data, array $schema ): ?Validation
582582
}
583583
if ( isset( $schema['enum'] ) ) {
584584
foreach ( $schema['enum'] as $enum_value ) {
585-
if ( ! $this->type_matches( $enum_value, $type ) ) {
585+
if ( ! $this->type_matches_any( $enum_value, $type ) ) {
586586
throw new UnsupportedSchemaException(
587587
'Enum value ' . json_encode( $enum_value ) . " does not match the declared type \"{$type}\"."
588588
);
@@ -731,10 +731,62 @@ private function validate_object( array $path, $data, array $schema ): ?Validati
731731
private function validate_array( array $path, array $data, array $schema ): ?ValidationError {
732732
$children_errors = array();
733733
if ( isset( $schema['items'] ) ) {
734-
foreach ( $data as $idx => $item ) {
735-
$error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'] );
736-
if ( $error ) {
737-
$children_errors[] = $error;
734+
// JSON schema supports two types of array validation:
735+
//
736+
// 1. List validation:
737+
// `{"type":"string"}`
738+
// A single schema object that all items in the data array must conform to.
739+
// In PHP, this is represented as an associative array,
740+
// `["type"=>"string"]`
741+
//
742+
// 2. Tuple validation:
743+
// `[{"type":"string"},{"type":"object"}]`,
744+
// A list of schemas, where each item in the data array is validated
745+
// against the schema at the same index.
746+
// In PHP, this is a numerically indexed array.
747+
// `[0=>['type'=>'string'],1=>['type'=>'object']]`.
748+
$is_tuple_schema = is_array( $schema['items'] ) && array_keys( $schema['items'] ) === range( 0, count( $schema['items'] ) - 1 );
749+
750+
if ( $is_tuple_schema ) {
751+
// Tuple validation.
752+
// Note: We do not support the "additionalItems" keyword.
753+
// Therefore, we treat it as an error if the data array contains more items
754+
// than are defined in the schema's "items" tuple.
755+
if ( count( $data ) > count( $schema['items'] ) ) {
756+
$children_errors[] = new ValidationError(
757+
$this->convert_path_to_string( $path ),
758+
'additional-items-not-allowed',
759+
'Tuple validation failed: expected at most ' . count( $schema['items'] ) . ' items but got ' . count( $data ) . '.',
760+
array(
761+
'expectedMaxCount' => count( $schema['items'] ),
762+
'actualCount' => count( $data ),
763+
)
764+
);
765+
}
766+
767+
foreach ( $data as $idx => $item ) {
768+
if ( isset( $schema['items'][ $idx ] ) ) {
769+
// Guard against a malformed schema where an item in the tuple is not a valid schema object.
770+
if ( ! is_array( $schema['items'][ $idx ] ) ) {
771+
throw new UnsupportedSchemaException( 'Invalid tuple schema: items must be schema objects, but a non-object was found at index ' . $idx );
772+
}
773+
// This item has a specific schema defined in the tuple.
774+
$error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'][ $idx ] );
775+
if ( $error ) {
776+
$children_errors[] = $error;
777+
}
778+
}
779+
// If there's no schema at $schema['items'][$idx],
780+
// the data item is an "additional item",
781+
// handled by the count check above.
782+
}
783+
} else {
784+
// List validation.
785+
foreach ( $data as $idx => $item ) {
786+
$error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'] );
787+
if ( $error ) {
788+
$children_errors[] = $error;
789+
}
738790
}
739791
}
740792
}

0 commit comments

Comments
 (0)