Skip to content

Commit 53725e0

Browse files
feat: add implementation for strict fully validating using draft-06 schema (#835)
This PR will: - Introduce the `JsonSchema\Constraints\Drafts\Draft06` namespace - A constraint per keyword - Enable the `draft6` in the test, locally I have 633 passing test, 312 failing tests and 157 ignored tests. # Update 22-08-2025: This PR has progressed nicely over time with a minimal impact on existing code. I'm currently trying to resolve the remaining issues from the pipeline (tests, style and static analysis) as well as fix quick wins/hardcoded solutions specific for Draft06 --------- Co-authored-by: matason <[email protected]>
1 parent 1ed7239 commit 53725e0

File tree

65 files changed

+2361
-105
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2361
-105
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99
### Added
1010
- Add lint check for class autoloading PSR compliance ([#845](https://github.com/jsonrainbow/json-schema/pull/845))
11+
- add implementation for strict fully validating using draft-06 schema ([#843](https://github.com/jsonrainbow/json-schema/pull/835))
1112

1213
## [6.5.2] - 2025-09-09
1314
### Fixed

README.md

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
[![Build Status](https://github.com/jsonrainbow/json-schema/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/jsonrainbow/json-schema/actions)
44
[![Latest Stable Version](https://poser.pugx.org/justinrainbow/json-schema/v/stable)](https://packagist.org/packages/justinrainbow/json-schema)
55
[![Total Downloads](https://poser.pugx.org/justinrainbow/json-schema/downloads)](https://packagist.org/packages/justinrainbow/json-schema/stats)
6+
![Supported Dialects](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fsupported_versions.json)
67

7-
A PHP Implementation for validating `JSON` Structures against a given `Schema` with support for `Schemas` of Draft-3 or Draft-4. Features of newer Drafts might not be supported. See [Table of All Versions of Everything](https://json-schema.org/specification-links.html#table-of-all-versions-of-everything) to get an overview of all existing Drafts.
8+
A PHP Implementation for validating `JSON` Structures against a given `Schema` with support for `Schemas` of Draft-3,
9+
Draft-4 or Draft-6.
10+
11+
Features of newer Drafts might not be supported. See [Table of All Versions of Everything](https://json-schema.org/specification-links.html#table-of-all-versions-of-everything) to get an overview
12+
of all existing Drafts. See [json-schema](http://json-schema.org/) for more details about the JSON Schema specification
13+
14+
# Compliance
15+
![Draft 3](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft3.json)
16+
![Draft 4](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft4.json)
17+
![Draft 6](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft6.json)
818

9-
See [json-schema](http://json-schema.org/) for more details.
1019

1120
## Installation
1221

@@ -187,24 +196,25 @@ A number of flags are available to alter the behavior of the validator. These ca
187196
third argument to `Validator::validate()`, or can be provided as the third argument to
188197
`Factory::__construct()` if you wish to persist them across multiple `validate()` calls.
189198

190-
| Flag | Description |
191-
|------|-------------|
192-
| `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default |
193-
| `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects |
194-
| `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible |
195-
| `Constraint::CHECK_MODE_EARLY_COERCE` | Apply type coercion as soon as possible |
196-
| `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set |
197-
| `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required |
198-
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |
199-
| `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints |
200-
| `Constraint::CHECK_MODE_VALIDATE_SCHEMA` | Validate the schema as well as the provided document |
201-
202-
Please note that using `CHECK_MODE_COERCE_TYPES` or `CHECK_MODE_APPLY_DEFAULTS` will modify your
199+
| Flag | Description |
200+
|-------------------------------------------------|-----------------------------------------------------------------|
201+
| `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default |
202+
| `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects |
203+
| `Constraint::CHECK_MODE_COERCE_TYPES` [^1][^2] | Convert data types to match the schema where possible |
204+
| `Constraint::CHECK_MODE_EARLY_COERCE` [^2] | Apply type coercion as soon as possible |
205+
| `Constraint::CHECK_MODE_APPLY_DEFAULTS` [^1] | Apply default values from the schema if not set |
206+
| `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required |
207+
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |
208+
| `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints |
209+
| `Constraint::CHECK_MODE_VALIDATE_SCHEMA` | Validate the schema as well as the provided document |
210+
| `Constraint::CHECK_MODE_STRICT` [^3] | Validate the scheme using strict mode using the specified draft |
211+
212+
[^1]: Please note that using `CHECK_MODE_COERCE_TYPES` or `CHECK_MODE_APPLY_DEFAULTS` will modify your
203213
original data.
204-
205-
`CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If
214+
[^2]: `CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If
206215
enabled, the validator will use (and coerce) the first compatible type it encounters, even if the
207216
schema defines another type that matches directly and does not require coercion.
217+
[^3]: `CHECK_MODE_STRICT` only can be used for Draft-6 at this point.
208218

209219
## Running the tests
210220

phpstan-baseline.neon

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,12 @@ parameters:
6666
path: src/JsonSchema/Constraints/Factory.php
6767

6868
-
69-
message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getConfig\\(\\) should return int\\<0, 511\\> but returns int\\.$#"
69+
message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getConfig\\(\\) should return int\\<0, 1023\\> but returns int\\.$#"
7070
count: 1
7171
path: src/JsonSchema/Constraints/Factory.php
7272

7373
-
74-
message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getErrorContext\\(\\) should return 1\\|2 but returns int\\.$#"
75-
count: 1
76-
path: src/JsonSchema/Constraints/Factory.php
77-
78-
-
79-
message: "#^Property JsonSchema\\\\Constraints\\\\Factory\\:\\:\\$checkMode \\(int\\<0, 511\\>\\) does not accept int\\.$#"
74+
message: "#^Property JsonSchema\\\\Constraints\\\\Factory\\:\\:\\$checkMode \\(int\\<0, 1023\\>\\) does not accept int\\.$#"
8075
count: 2
8176
path: src/JsonSchema/Constraints/Factory.php
8277

@@ -630,16 +625,6 @@ parameters:
630625
count: 1
631626
path: src/JsonSchema/Iterator/ObjectIterator.php
632627

633-
-
634-
message: "#^Offset 4 does not exist on array\\{0\\: string, 1\\: non\\-falsy\\-string, 2\\: string, 3\\: non\\-falsy\\-string, 4\\?\\: string, 5\\?\\: non\\-falsy\\-string&numeric\\-string\\}\\.$#"
635-
count: 1
636-
path: src/JsonSchema/Rfc3339.php
637-
638-
-
639-
message: "#^Offset 5 does not exist on array\\{0\\: string, 1\\: non\\-falsy\\-string, 2\\: string, 3\\: non\\-falsy\\-string, 4\\?\\: string, 5\\?\\: non\\-falsy\\-string&numeric\\-string\\}\\.$#"
640-
count: 1
641-
path: src/JsonSchema/Rfc3339.php
642-
643628
-
644629
message: "#^Access to an undefined property object\\:\\:\\$properties\\.$#"
645630
count: 3
@@ -656,7 +641,7 @@ parameters:
656641
path: src/JsonSchema/SchemaStorage.php
657642

658643
-
659-
message: "#^Call to function is_array\\(\\) with object will always evaluate to false\\.$#"
644+
message: "#^Call to function is_array\\(\\) with bool|object will always evaluate to false\\.$#"
660645
count: 1
661646
path: src/JsonSchema/SchemaStorage.php
662647

src/JsonSchema/ConstraintError.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,23 @@ class ConstraintError extends Enum
1717
public const DIVISIBLE_BY = 'divisibleBy';
1818
public const ENUM = 'enum';
1919
public const CONSTANT = 'const';
20+
public const CONTAINS = 'contains';
2021
public const EXCLUSIVE_MINIMUM = 'exclusiveMinimum';
2122
public const EXCLUSIVE_MAXIMUM = 'exclusiveMaximum';
23+
public const FALSE = 'false';
2224
public const FORMAT_COLOR = 'colorFormat';
2325
public const FORMAT_DATE = 'dateFormat';
2426
public const FORMAT_DATE_TIME = 'dateTimeFormat';
2527
public const FORMAT_DATE_UTC = 'dateUtcFormat';
2628
public const FORMAT_EMAIL = 'emailFormat';
2729
public const FORMAT_HOSTNAME = 'styleHostName';
2830
public const FORMAT_IP = 'ipFormat';
31+
public const FORMAT_JSON_POINTER = 'jsonPointerFormat';
2932
public const FORMAT_PHONE = 'phoneFormat';
3033
public const FORMAT_REGEX= 'regexFormat';
3134
public const FORMAT_STYLE = 'styleFormat';
3235
public const FORMAT_TIME = 'timeFormat';
36+
public const FORMAT_URI_TEMPLATE = 'uriTemplateFormat';
3337
public const FORMAT_URL = 'urlFormat';
3438
public const FORMAT_URL_REF = 'urlRefFormat';
3539
public const INVALID_SCHEMA = 'invalidSchema';
@@ -51,6 +55,7 @@ class ConstraintError extends Enum
5155
public const PREGEX_INVALID = 'pregrex';
5256
public const PROPERTIES_MIN = 'minProperties';
5357
public const PROPERTIES_MAX = 'maxProperties';
58+
public const PROPERTY_NAMES = 'propertyNames';
5459
public const TYPE = 'type';
5560
public const UNIQUE_ITEMS = 'uniqueItems';
5661

@@ -70,19 +75,23 @@ public function getMessage()
7075
self::DIVISIBLE_BY => 'Is not divisible by %d',
7176
self::ENUM => 'Does not have a value in the enumeration %s',
7277
self::CONSTANT => 'Does not have a value equal to %s',
78+
self::CONTAINS => 'Does not have a value valid to contains schema',
7379
self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %d',
7480
self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %d',
81+
self::FALSE => 'Boolean schema false',
7582
self::FORMAT_COLOR => 'Invalid color',
7683
self::FORMAT_DATE => 'Invalid date %s, expected format YYYY-MM-DD',
7784
self::FORMAT_DATE_TIME => 'Invalid date-time %s, expected format YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DDThh:mm:ss+hh:mm',
7885
self::FORMAT_DATE_UTC => 'Invalid time %s, expected integer of milliseconds since Epoch',
7986
self::FORMAT_EMAIL => 'Invalid email',
8087
self::FORMAT_HOSTNAME => 'Invalid hostname',
8188
self::FORMAT_IP => 'Invalid IP address',
89+
self::FORMAT_JSON_POINTER => 'Invalid JSON pointer',
8290
self::FORMAT_PHONE => 'Invalid phone number',
8391
self::FORMAT_REGEX=> 'Invalid regex format %s',
8492
self::FORMAT_STYLE => 'Invalid style',
8593
self::FORMAT_TIME => 'Invalid time %s, expected format hh:mm:ss',
94+
self::FORMAT_URI_TEMPLATE => 'Invalid URI template format',
8695
self::FORMAT_URL => 'Invalid URL format',
8796
self::FORMAT_URL_REF => 'Invalid URL reference format',
8897
self::LENGTH_MAX => 'Must be at most %d characters long',
@@ -104,6 +113,7 @@ public function getMessage()
104113
self::PREGEX_INVALID => 'The pattern %s is invalid',
105114
self::PROPERTIES_MIN => 'Must contain a minimum of %d properties',
106115
self::PROPERTIES_MAX => 'Must contain no more than %d properties',
116+
self::PROPERTY_NAMES => 'Property name %s is invalid',
107117
self::TYPE => '%s value found, but %s is required',
108118
self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array'
109119
];

src/JsonSchema/Constraints/Constraint.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface
2121
public const CHECK_MODE_EARLY_COERCE = 0x00000040;
2222
public const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080;
2323
public const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100;
24+
public const CHECK_MODE_STRICT = 0x00000200;
2425

2526
/**
2627
* Bubble down the path
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Entity\ErrorBagProxy;
10+
use JsonSchema\Entity\JsonPointer;
11+
12+
class AdditionalItemsConstraint implements ConstraintInterface
13+
{
14+
use ErrorBagProxy;
15+
16+
/** @var Factory */
17+
private $factory;
18+
19+
public function __construct(?Factory $factory = null)
20+
{
21+
$this->factory = $factory ?: new Factory();
22+
$this->initialiseErrorBag($this->factory);
23+
}
24+
25+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
26+
{
27+
if (!property_exists($schema, 'additionalItems')) {
28+
return;
29+
}
30+
31+
if ($schema->additionalItems === true) {
32+
return;
33+
}
34+
if ($schema->additionalItems === false && !property_exists($schema, 'items')) {
35+
return;
36+
}
37+
38+
if (!is_array($value)) {
39+
return;
40+
}
41+
if (!property_exists($schema, 'items')) {
42+
return;
43+
}
44+
if (property_exists($schema, 'items') && is_object($schema->items)) {
45+
return;
46+
}
47+
48+
$additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []);
49+
50+
foreach ($additionalItems as $propertyName => $propertyValue) {
51+
$schemaConstraint = $this->factory->createInstanceFor('schema');
52+
$schemaConstraint->check($propertyValue, $schema->additionalItems, $path, $i);
53+
54+
if ($schemaConstraint->isValid()) {
55+
continue;
56+
}
57+
58+
$this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]);
59+
}
60+
}
61+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Entity\ErrorBagProxy;
10+
use JsonSchema\Entity\JsonPointer;
11+
12+
class AdditionalPropertiesConstraint implements ConstraintInterface
13+
{
14+
use ErrorBagProxy;
15+
16+
/** @var Factory */
17+
private $factory;
18+
19+
public function __construct(?Factory $factory = null)
20+
{
21+
$this->factory = $factory ?: new Factory();
22+
$this->initialiseErrorBag($this->factory);
23+
}
24+
25+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
26+
{
27+
if (!property_exists($schema, 'additionalProperties')) {
28+
return;
29+
}
30+
31+
if ($schema->additionalProperties === true) {
32+
return;
33+
}
34+
35+
if (!is_object($value)) {
36+
return;
37+
}
38+
39+
$additionalProperties = get_object_vars($value);
40+
41+
if (isset($schema->properties)) {
42+
$additionalProperties = array_diff_key($additionalProperties, (array) $schema->properties);
43+
}
44+
45+
if (isset($schema->patternProperties)) {
46+
$patterns = array_keys(get_object_vars($schema->patternProperties));
47+
48+
foreach ($additionalProperties as $key => $_) {
49+
foreach ($patterns as $pattern) {
50+
if (preg_match($this->createPregMatchPattern($pattern), (string) $key)) {
51+
unset($additionalProperties[$key]);
52+
break;
53+
}
54+
}
55+
}
56+
}
57+
58+
if (is_object($schema->additionalProperties)) {
59+
foreach ($additionalProperties as $key => $additionalPropertiesValue) {
60+
$schemaConstraint = $this->factory->createInstanceFor('schema');
61+
$schemaConstraint->check($additionalPropertiesValue, $schema->additionalProperties, $path, $i); // @todo increment path
62+
if ($schemaConstraint->isValid()) {
63+
unset($additionalProperties[$key]);
64+
}
65+
}
66+
}
67+
68+
foreach ($additionalProperties as $key => $additionalPropertiesValue) {
69+
$this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $additionalPropertiesValue]);
70+
}
71+
}
72+
73+
private function createPregMatchPattern(string $pattern): string
74+
{
75+
$replacements = [
76+
// '\D' => '[^0-9]',
77+
// '\d' => '[0-9]',
78+
'\p{digit}' => '\p{Nd}',
79+
// '\w' => '[A-Za-z0-9_]',
80+
// '\W' => '[^A-Za-z0-9_]',
81+
// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space,
82+
'\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations
83+
];
84+
85+
$pattern = str_replace(
86+
array_keys($replacements),
87+
array_values($replacements),
88+
$pattern
89+
);
90+
91+
return '/' . str_replace('/', '\/', $pattern) . '/u';
92+
}
93+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Entity\ErrorBagProxy;
10+
use JsonSchema\Entity\JsonPointer;
11+
12+
class AllOfConstraint implements ConstraintInterface
13+
{
14+
use ErrorBagProxy;
15+
16+
/** @var Factory */
17+
private $factory;
18+
19+
public function __construct(?Factory $factory = null)
20+
{
21+
$this->factory = $factory ?: new Factory();
22+
$this->initialiseErrorBag($this->factory);
23+
}
24+
25+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
26+
{
27+
if (!property_exists($schema, 'allOf')) {
28+
return;
29+
}
30+
31+
foreach ($schema->allOf as $allOfSchema) {
32+
$schemaConstraint = $this->factory->createInstanceFor('schema');
33+
$schemaConstraint->check($value, $allOfSchema, $path, $i);
34+
35+
if ($schemaConstraint->isValid()) {
36+
continue;
37+
}
38+
$this->addError(ConstraintError::ALL_OF(), $path);
39+
$this->addErrors($schemaConstraint->getErrors());
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)