From 90a858e0650db0b0d259f0465d2c58ffe72ba5af Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Wed, 25 Jun 2025 20:43:28 +0200 Subject: [PATCH 01/21] feat: add proof-of-concept implementation for strictfully validating using draft-06 schema --- src/JsonSchema/Constraints/Constraint.php | 1 + .../Drafts/Draft06/ConstConstraint.php | 35 +++++ .../Drafts/Draft06/Draft06Constraint.php | 50 ++++++ .../Drafts/Draft06/EnumConstraint.php | 42 +++++ .../Draft06/ExclusiveMinimumConstraint.php | 34 ++++ .../Constraints/Drafts/Draft06/Factory.php | 25 +++ .../Drafts/Draft06/MaxItemsConstraint.php | 39 +++++ .../Drafts/Draft06/MinItemsConstraint.php | 39 +++++ .../Drafts/Draft06/MinLengthConstraint.php | 39 +++++ .../Draft06/MinPropertiesConstraint.php | 39 +++++ .../Drafts/Draft06/MinimumConstraint.php | 34 ++++ .../Drafts/Draft06/NumberConstraint.php | 29 ++++ .../Drafts/Draft06/TypeConstraint.php | 29 ++++ .../Drafts/Draft06/UniqueItemsConstraint.php | 44 ++++++ src/JsonSchema/Constraints/Factory.php | 3 +- src/JsonSchema/Entity/ErrorBag.php | 105 +++++++++++++ src/JsonSchema/Entity/ErrorBagProxy.php | 61 ++++++++ src/JsonSchema/Validator.php | 32 +++- tests/Constraints/BaseTestCase.php | 2 +- tests/Constraints/VeryBaseTestCase.php | 6 +- tests/Drafts/Draft6Test.php | 146 ++++++++++++++++++ tests/JsonSchemaTestSuiteTest.php | 8 +- 22 files changed, 833 insertions(+), 9 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/Factory.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php create mode 100644 src/JsonSchema/Entity/ErrorBag.php create mode 100644 src/JsonSchema/Entity/ErrorBagProxy.php create mode 100644 tests/Drafts/Draft6Test.php diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index 8e818f0a..3c7e824b 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -21,6 +21,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface public const CHECK_MODE_EARLY_COERCE = 0x00000040; public const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080; public const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100; + public const CHECK_MODE_STRICT = 0x00000200; /** * Bubble down the path diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php new file mode 100644 index 00000000..563bd42c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php @@ -0,0 +1,35 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'const')) { + return; + } + + if (DeepComparer::isEqual($value, $schema->const)) { + return; + } + + $this->addError(ConstraintError::CONSTANT(), $path, ['const' => $schema->const]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php new file mode 100644 index 00000000..c15cddd1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -0,0 +1,50 @@ +checkForKeyword('type', $value, $schema, $path, $i); + // Not + // Dependencies + // allof + // anyof + // oneof + + // array + // object + // string + $this->checkForKeyword('number', $value, $schema, $path, $i); + $this->checkForKeyword('uniqueItems', $value, $schema, $path, $i); + $this->checkForKeyword('minItems', $value, $schema, $path, $i); + $this->checkForKeyword('minProperties', $value, $schema, $path, $i); + $this->checkForKeyword('minimum', $value, $schema, $path, $i); + $this->checkForKeyword('minLength', $value, $schema, $path, $i); + $this->checkForKeyword('exclusiveMinimum', $value, $schema, $path, $i); + $this->checkForKeyword('maxItems', $value, $schema, $path, $i); + $this->checkForKeyword('enum', $value, $schema, $path, $i); + $this->checkForKeyword('const', $value, $schema, $path, $i); + } + + protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + $validator = $this->factory->createInstanceFor($keyword); + $validator->check($value, $schema, $path, $i); + + $this->addErrors($validator->getErrors()); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php new file mode 100644 index 00000000..b83af1c9 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php @@ -0,0 +1,42 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'enum')) { + return; + } + + foreach ($schema->enum as $enumCase) { + if (DeepComparer::isEqual($value, $enumCase)) { + return; + } + + if (is_numeric($value) && is_numeric($enumCase) && DeepComparer::isEqual((float) $value, (float) $enumCase)) { + return; + } + } + + $this->addError(ConstraintError::ENUM(), $path, ['enum' => $schema->enum]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php new file mode 100644 index 00000000..1920b4d3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php @@ -0,0 +1,34 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'exclusiveMinimum')) { + return; + } + + if ($value > $schema->exclusiveMinimum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, ['exclusiveMinimum' => $schema->exclusiveMinimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php new file mode 100644 index 00000000..8031c847 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -0,0 +1,25 @@ + + */ + protected $constraintMap = [ + 'type' => TypeConstraint::class, + 'const' => ConstConstraint::class, + 'enum' => EnumConstraint::class, + 'number' => NumberConstraint::class, + 'uniqueItems' => UniqueItemsConstraint::class, + 'minItems' => MinItemsConstraint::class, + 'minProperties' => MinPropertiesConstraint::class, + 'minimum' => MinimumConstraint::class, + 'exclusiveMinimum' => ExclusiveMinimumConstraint::class, + 'minLength' => MinLengthConstraint::class, + 'maxItems' => MaxItemsConstraint::class, + ]; +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php new file mode 100644 index 00000000..d7ad2649 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxItems')) { + return; + } + + if (!is_array($value)) { + return; + } + + $count = count($value); + if ($count <= $schema->maxItems) { + return; + } + + $this->addError(ConstraintError::MAX_ITEMS(), $path, ['maxItems' => $schema->maxItems, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php new file mode 100644 index 00000000..b17cd894 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minItems')) { + return; + } + + if (!is_array($value)) { + return; + } + + $count = count($value); + if ($count >= $schema->minItems) { + return; + } + + $this->addError(ConstraintError::MIN_ITEMS(), $path, ['minItems' => $schema->minItems, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php new file mode 100644 index 00000000..d9c516a3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minLength')) { + return; + } + + if (!is_string($value)) { + return; + } + + $length = mb_strlen($value); + if ($length >= $schema->minLength) { + return; + } + + $this->addError(ConstraintError::LENGTH_MIN(), $path, ['minLength' => $schema->minLength, 'found' => $length]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php new file mode 100644 index 00000000..148b4055 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $count = count(get_object_vars($value)); + if ($count >= $schema->minProperties) { + return; + } + + $this->addError(ConstraintError::PROPERTIES_MIN(), $path, ['minProperties' => $schema->minProperties, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php new file mode 100644 index 00000000..aca6c1d3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php @@ -0,0 +1,34 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minimum')) { + return; + } + + if ($value >= $schema->minimum) { + return; + } + + $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php new file mode 100644 index 00000000..96b7d0e1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php @@ -0,0 +1,29 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'type')) { + return; + } + + throw new \Exception('Implement check method'); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php new file mode 100644 index 00000000..1b780411 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -0,0 +1,29 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'type')) { + return; + } + + throw new \Exception('Implement check method'); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php new file mode 100644 index 00000000..75a21c15 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php @@ -0,0 +1,44 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'uniqueItems')) { + return; + } + + if ($schema->uniqueItems !== true) { + // If unique items not is true duplicates are allowed. + return; + } + + $count = count($value); + for ($x = 0; $x < $count - 1; $x++) { + for ($y = $x + 1; $y < $count; $y++) { + if (DeepComparer::isEqual($value[$x], $value[$y])) { + $this->addError(ConstraintError::UNIQUE_ITEMS(), $path); + return; + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Factory.php b/src/JsonSchema/Constraints/Factory.php index b9220b9d..f613e2a0 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -65,7 +65,8 @@ class Factory 'const' => 'JsonSchema\Constraints\ConstConstraint', 'format' => 'JsonSchema\Constraints\FormatConstraint', 'schema' => 'JsonSchema\Constraints\SchemaConstraint', - 'validator' => 'JsonSchema\Validator' + 'validator' => 'JsonSchema\Validator', + 'draft06' => Drafts\Draft06\Draft06Constraint::class, ]; /** diff --git a/src/JsonSchema/Entity/ErrorBag.php b/src/JsonSchema/Entity/ErrorBag.php new file mode 100644 index 00000000..b2eaee90 --- /dev/null +++ b/src/JsonSchema/Entity/ErrorBag.php @@ -0,0 +1,105 @@ +}, + * context: int + * } + * @phpstan-type ErrorList list + */ +class ErrorBag +{ + /** @var Factory */ + private $factory; + + /** @var ErrorList */ + private $errors = []; + + /** + * @var int All error types that have occurred + * @phpstan-var int-mask-of + */ + protected $errorMask = Validator::ERROR_NONE; + + public function __construct(Factory $factory) + { + $this->factory = $factory; + } + + /** @return ErrorList */ + public function getErrors(): array + { + return $this->errors; + } + + /** @param array $more */ + public function addError(ConstraintError $constraint, ?JsonPointer $path = null, array $more = []): void + { + $message = $constraint->getMessage(); + $name = $constraint->getValue(); + $error = [ + 'property' => $this->convertJsonPointerIntoPropertyPath($path ?: new JsonPointer('')), + 'pointer' => ltrim((string) ($path ?: new JsonPointer('')), '#'), + 'message' => ucfirst(vsprintf($message, array_map(static function ($val) { + if (is_scalar($val)) { + return is_bool($val) ? var_export($val, true) : $val; + } + + return json_encode($val); + }, array_values($more)))), + 'constraint' => [ + 'name' => $name, + 'params' => $more + ], + 'context' => $this->factory->getErrorContext(), + ]; + + if ($this->factory->getConfig(Constraint::CHECK_MODE_EXCEPTIONS)) { + throw new ValidationException(sprintf('Error validating %s: %s', $error['pointer'], $error['message'])); + } + + $this->errors[] = $error; + $this->errorMask |= $error['context']; + } + + /** @param ErrorList $errors */ + public function addErrors(array $errors): void + { + if (! $errors) { + return; + } + + $this->errors = array_merge($this->errors, $errors); + $errorMask = &$this->errorMask; + array_walk($errors, static function ($error) use (&$errorMask) { + if (isset($error['context'])) { + $errorMask |= $error['context']; + } + }); + } + + private function convertJsonPointerIntoPropertyPath(JsonPointer $pointer): string + { + $result = array_map( + static function ($path) { + return sprintf(is_numeric($path) ? '[%d]' : '.%s', $path); + }, + $pointer->getPropertyPaths() + ); + + return trim(implode('', $result), '.'); + } +} diff --git a/src/JsonSchema/Entity/ErrorBagProxy.php b/src/JsonSchema/Entity/ErrorBagProxy.php new file mode 100644 index 00000000..17deba33 --- /dev/null +++ b/src/JsonSchema/Entity/ErrorBagProxy.php @@ -0,0 +1,61 @@ +errorBag()->getErrors(); + } + + /** @param ErrorList $errors */ + public function addErrors(array $errors): void + { + $this->errorBag()->addErrors($errors); + } + + /** + * @param array $more more array elements to add to the error + */ + public function addError(ConstraintError $constraint, ?JsonPointer $path = null, array $more = []): void + { + $this->errorBag()->addError($constraint, $path, $more); + } + + public function isValid(): bool + { + return $this->errorBag()->getErrors() === []; + } + + protected function initialiseErrorBag(Factory $factory): ErrorBag + { + if (!isset($this->errorBag)) { + $this->errorBag = new ErrorBag($factory); + } + + return $this->errorBag; + } + + protected function errorBag(): ErrorBag + { + if (!isset($this->errorBag)) { + throw new \RuntimeException('ErrorBag not initialized'); + } + + return $this->errorBag; + } +} diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index 0845b0cb..f14aa3fc 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -64,10 +64,20 @@ public function validate(&$value, $schema = null, ?int $checkMode = null): int $this->factory->getSchemaStorage()->addSchema($schemaURI, $schema); $validator = $this->factory->createInstanceFor('schema'); - $validator->check( - $value, - $this->factory->getSchemaStorage()->getSchema($schemaURI) - ); + $schema = $this->factory->getSchemaStorage()->getSchema($schemaURI); + + // Boolean schema requires no further validation + if (is_bool($schema)) { + return $validator->getErrorMask(); + } + + if ($this->factory->getConfig(Constraint::CHECK_MODE_STRICT)) { + $validator = $this->factory->createInstanceFor( + $this->schemaUriToConstraintName($schema->{'$schema'}) + ); + } + + $validator->check($value, $schema); $this->factory->setConfig($initialCheckMode); @@ -105,4 +115,18 @@ public function coerce(&$value, $schema): int { return $this->validate($value, $schema, Constraint::CHECK_MODE_COERCE_TYPES); } + + private function schemaUriToConstraintName(string $schemaUri): string + { + switch ($schemaUri) { + case 'http://json-schema.org/draft-03/schema#': + return 'draft03'; + case 'http://json-schema.org/draft-04/schema#': + return 'draft04'; + case 'http://json-schema.org/draft-06/schema#': + return 'draft06'; + } + + throw new \Exception('Unsupported schema URI: ' . $schemaUri); + } } diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index 63b63a42..67017a2d 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -88,7 +88,7 @@ public function testInvalidCasesUsingAssoc(string $input, string $schema, ?int $ * * @param ?int-mask-of $checkMode */ - public function testValidCases(string $input, string $schema, ?int $checkMode = Constraint::CHECK_MODE_NORMAL): void + public function testValidCases(string $input, string $schema, int $checkMode = Constraint::CHECK_MODE_NORMAL): void { if ($this->validateSchema) { $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; diff --git a/tests/Constraints/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index f468ac0b..5ba4ee71 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -17,7 +17,11 @@ abstract class VeryBaseTestCase extends TestCase /** @var array */ private $draftSchemas = []; - protected function getUriRetrieverMock(?object $schema): object + /** + * @param object|bool|null $schema + * @return object + */ + protected function getUriRetrieverMock($schema): object { $uriRetriever = $this->prophesize(UriRetrieverInterface::class); $uriRetriever->retrieve($schema->id ?? 'http://www.my-domain.com/schema.json') diff --git a/tests/Drafts/Draft6Test.php b/tests/Drafts/Draft6Test.php new file mode 100644 index 00000000..819321e6 --- /dev/null +++ b/tests/Drafts/Draft6Test.php @@ -0,0 +1,146 @@ +validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + $checkMode |= Constraint::CHECK_MODE_STRICT; + + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + + // add `$schema` if missing + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } + + $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); + $checkValue = json_decode($input, false); + $errorMask = $validator->validate($checkValue, $schema); + + $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION), 'Document is invalid'); + $this->assertGreaterThan(0, $validator->numErrors()); + + if ([] !== $errors) { + $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + } + $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getInvalidForAssocTests + */ + public function testInvalidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST, $errors = []): void + { + $checkMode = $checkMode === null ? Constraint::CHECK_MODE_TYPE_CAST : $checkMode; + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { + $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); + } + + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } + + $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); + $checkValue = json_decode($input, true); + $errorMask = $validator->validate($checkValue, $schema); + + $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION)); + $this->assertGreaterThan(0, $validator->numErrors()); + + if ([] !== $errors) { + $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + } + $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getValidTests + */ + public function testValidCases(string $input, string $schema, int $checkMode = Constraint::CHECK_MODE_NORMAL): void + { + $schemaObject = json_decode($schema); + $checkMode |= Constraint::CHECK_MODE_STRICT; + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schemaObject)); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } + + $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); + $checkValue = json_decode($input, false); + $errorMask = $validator->validate($checkValue, $schema); + $this->assertEquals(0, $errorMask); + + $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getValidForAssocTests + */ + public function testValidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST): void + { + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { + $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); + } + + $schema = json_decode($schema); + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schema), new UriResolver()); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } + + $value = json_decode($input, true); + $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); + + $errorMask = $validator->validate($value, $schema); + $this->assertEquals(0, $errorMask, $this->validatorErrorsToString($validator)); + $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + } + /** + * {@inheritdoc} + */ + protected function getFilePaths(): array + { + return [ + realpath(__DIR__ . $this->relativeTestsRoot . '/draft6'), + realpath(__DIR__ . $this->relativeTestsRoot . '/draft6/optional') + ]; + } + + protected function getSkippedTests(): array + { + return []; + } +} diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 0c4931bf..953f9183 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -5,6 +5,7 @@ namespace JsonSchema\Tests; use CallbackFilterIterator; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; use JsonSchema\SchemaStorage; use JsonSchema\SchemaStorageInterface; @@ -35,7 +36,7 @@ public function testTestCaseValidatesCorrectly( $validator = new Validator(new Factory($schemaStorage)); try { - $validator->validate($data, $schema); + $validator->validate($data, $schema, Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT); } catch (\Exception $e) { if ($optional) { $this->markTestSkipped('Optional test case would during validate() invocation'); @@ -57,7 +58,7 @@ public function casesDataProvider(): \Generator $drafts = array_filter(glob($testDir . '/*'), static function (string $filename) { return is_dir($filename); }); - $skippedDrafts = ['draft6', 'draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; + $skippedDrafts = ['draft3', 'draft4', 'draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { if (in_array(basename($draft), $skippedDrafts, true)) { @@ -76,6 +77,9 @@ function ($file) { foreach ($files as $file) { $contents = json_decode(file_get_contents($file->getPathname()), false); foreach ($contents as $testCase) { + if (is_object($testCase->schema)) { + $testCase->schema->{'$schema'} = 'http://json-schema.org/draft-06/schema#'; // Hardcode $schema property in schema + } foreach ($testCase->tests as $test) { $name = sprintf( '[%s/%s%s]: %s: %s is expected to be %s', From 6ef38b393b9e20e7bf764e8aeff77ee6b0e50ca6 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Wed, 25 Jun 2025 21:34:43 +0200 Subject: [PATCH 02/21] feat: more progress on draft-06 proof of concept --- .../AdditionalPropertiesConstraint.php | 42 ++++++++++ .../Drafts/Draft06/DependenciesConstraint.php | 40 +++++++++ .../Drafts/Draft06/Draft06Constraint.php | 7 +- ...int.php => ExclusiveMaximumConstraint.php} | 11 ++- .../Constraints/Drafts/Draft06/Factory.php | 6 +- .../Drafts/Draft06/MaxLengthConstraint.php | 39 +++++++++ .../Draft06/MaxPropertiesConstraint.php | 39 +++++++++ .../Drafts/Draft06/TypeConstraint.php | 12 ++- .../Constraints/NumberConstraint.php | 84 ------------------- tests/Constraints/BaseTestCase.php | 2 +- tests/Drafts/Draft6Test.php | 10 ++- 11 files changed, 197 insertions(+), 95 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php rename src/JsonSchema/Constraints/Drafts/Draft06/{NumberConstraint.php => ExclusiveMaximumConstraint.php} (58%) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php delete mode 100644 src/JsonSchema/Constraints/NumberConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php new file mode 100644 index 00000000..3b8301f8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -0,0 +1,42 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'additionalProperties')) { + return; + } + + if ($schema->additionalProperties === true) { + return; + } + + if (!is_object($value)) { + return; + } + + $additionalProperties = array_diff_key(get_object_vars($value), (array) $schema->properties); + // @todo additional properties should bechecked against the patternProperties + if ($schema->additionalProperties === false && $additionalProperties !== []) { + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['additionalProperties' => array_keys($additionalProperties)]); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php new file mode 100644 index 00000000..8a9a903f --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php @@ -0,0 +1,40 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'dependencies')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->dependencies as $dependant => $dependencies) { + foreach ($dependencies as $dependency) { + if (property_exists($value, $dependant) && !property_exists($value, $dependency)) { + $this->addError(ConstraintError::DEPENDENCIES(), $path, ['dependant' => $dependant, 'dependency' => $dependency]); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index c15cddd1..19b19f17 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -20,7 +20,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n // Required keyword $this->checkForKeyword('type', $value, $schema, $path, $i); // Not - // Dependencies + $this->checkForKeyword('dependencies', $value, $schema, $path, $i); // allof // anyof // oneof @@ -28,14 +28,17 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n // array // object // string - $this->checkForKeyword('number', $value, $schema, $path, $i); + $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); $this->checkForKeyword('uniqueItems', $value, $schema, $path, $i); $this->checkForKeyword('minItems', $value, $schema, $path, $i); $this->checkForKeyword('minProperties', $value, $schema, $path, $i); + $this->checkForKeyword('maxProperties', $value, $schema, $path, $i); $this->checkForKeyword('minimum', $value, $schema, $path, $i); $this->checkForKeyword('minLength', $value, $schema, $path, $i); $this->checkForKeyword('exclusiveMinimum', $value, $schema, $path, $i); $this->checkForKeyword('maxItems', $value, $schema, $path, $i); + $this->checkForKeyword('maxLength', $value, $schema, $path, $i); + $this->checkForKeyword('exclusiveMaximum', $value, $schema, $path, $i); $this->checkForKeyword('enum', $value, $schema, $path, $i); $this->checkForKeyword('const', $value, $schema, $path, $i); } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php similarity index 58% rename from src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php rename to src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php index 96b7d0e1..9694e4f8 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/NumberConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php @@ -4,12 +4,13 @@ namespace JsonSchema\Constraints\Drafts\Draft06; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -class NumberConstraint implements ConstraintInterface +class ExclusiveMaximumConstraint implements ConstraintInterface { use ErrorBagProxy; @@ -20,10 +21,14 @@ public function __construct(?Factory $factory = null) public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { - if (!property_exists($schema, 'type')) { + if (!property_exists($schema, 'exclusiveMaximum')) { return; } - throw new \Exception('Implement check method'); + if ($value < $schema->exclusiveMaximum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, ['exclusiveMaximum' => $schema->exclusiveMaximum, 'found' => $value]); } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index 8031c847..f46445cf 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -10,16 +10,20 @@ class Factory extends \JsonSchema\Constraints\Factory * @var array */ protected $constraintMap = [ + 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'dependencies' => DependenciesConstraint::class, 'type' => TypeConstraint::class, 'const' => ConstConstraint::class, 'enum' => EnumConstraint::class, - 'number' => NumberConstraint::class, 'uniqueItems' => UniqueItemsConstraint::class, 'minItems' => MinItemsConstraint::class, 'minProperties' => MinPropertiesConstraint::class, + 'maxProperties' => MaxPropertiesConstraint::class, 'minimum' => MinimumConstraint::class, 'exclusiveMinimum' => ExclusiveMinimumConstraint::class, 'minLength' => MinLengthConstraint::class, + 'maxLength' => MaxLengthConstraint::class, 'maxItems' => MaxItemsConstraint::class, + 'exclusiveMaximum' => ExclusiveMaximumConstraint::class, ]; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php new file mode 100644 index 00000000..5243f488 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxLength')) { + return; + } + + if (!is_string($value)) { + return; + } + + $length = mb_strlen($value); + if ($length <= $schema->maxLength) { + return; + } + + $this->addError(ConstraintError::LENGTH_MAX(), $path, ['maxLength' => $schema->maxLength, 'found' => $length]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php new file mode 100644 index 00000000..b881a2d5 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $count = count(get_object_vars($value)); + if ($count <= $schema->maxProperties) { + return; + } + + $this->addError(ConstraintError::PROPERTIES_MAX(), $path, ['maxProperties' => $schema->maxProperties, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php index 1b780411..4bcc6cc9 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -4,6 +4,7 @@ namespace JsonSchema\Constraints\Drafts\Draft06; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; @@ -24,6 +25,15 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - throw new \Exception('Implement check method'); + $schemaTypes = (array) $schema->type; + $valueType = gettype($value); + + foreach ($schemaTypes as $type) { + if ($valueType === $type) { + return; + } + } + + $this->addError(ConstraintError::TYPE(), $path, ['found' => $valueType, 'expected' => implode(', ', $schemaTypes)]); } } diff --git a/src/JsonSchema/Constraints/NumberConstraint.php b/src/JsonSchema/Constraints/NumberConstraint.php deleted file mode 100644 index e1b2ffc3..00000000 --- a/src/JsonSchema/Constraints/NumberConstraint.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @author Bruno Prieto Reis - */ -class NumberConstraint extends Constraint -{ - /** - * {@inheritdoc} - */ - public function check(&$element, $schema = null, ?JsonPointer $path = null, $i = null): void - { - // Verify minimum - if (isset($schema->exclusiveMinimum)) { - if (isset($schema->minimum)) { - if ($schema->exclusiveMinimum && $element <= $schema->minimum) { - $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, ['minimum' => $schema->minimum]); - } elseif ($element < $schema->minimum) { - $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum]); - } - } else { - $this->addError(ConstraintError::MISSING_MINIMUM(), $path); - } - } elseif (isset($schema->minimum) && $element < $schema->minimum) { - $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum]); - } - - // Verify maximum - if (isset($schema->exclusiveMaximum)) { - if (isset($schema->maximum)) { - if ($schema->exclusiveMaximum && $element >= $schema->maximum) { - $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, ['maximum' => $schema->maximum]); - } elseif ($element > $schema->maximum) { - $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum]); - } - } else { - $this->addError(ConstraintError::MISSING_MAXIMUM(), $path); - } - } elseif (isset($schema->maximum) && $element > $schema->maximum) { - $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum]); - } - - // Verify divisibleBy - Draft v3 - if (isset($schema->divisibleBy) && $this->fmod($element, $schema->divisibleBy) != 0) { - $this->addError(ConstraintError::DIVISIBLE_BY(), $path, ['divisibleBy' => $schema->divisibleBy]); - } - - // Verify multipleOf - Draft v4 - if (isset($schema->multipleOf) && $this->fmod($element, $schema->multipleOf) != 0) { - $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf]); - } - - $this->checkFormat($element, $schema, $path, $i); - } - - private function fmod($number1, $number2) - { - $modulus = ($number1 - round($number1 / $number2) * $number2); - $precision = 0.0000000001; - - if (-$precision < $modulus && $modulus < $precision) { - return 0.0; - } - - return $modulus; - } -} diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index 67017a2d..9b67eedd 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -152,7 +152,7 @@ public function getInvalidForAssocTests(): Generator yield from $this->getInvalidTests(); } - private function validatorErrorsToString(Validator $validator): string + protected function validatorErrorsToString(Validator $validator): string { return implode( ', ', diff --git a/tests/Drafts/Draft6Test.php b/tests/Drafts/Draft6Test.php index 819321e6..9678fee3 100644 --- a/tests/Drafts/Draft6Test.php +++ b/tests/Drafts/Draft6Test.php @@ -37,7 +37,7 @@ public function testInvalidCases($input, $schema, $checkMode = Constraint::CHECK $checkValue = json_decode($input, false); $errorMask = $validator->validate($checkValue, $schema); - $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION), 'Document is invalid'); + $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION), 'Document is invalid: ' .print_r($validator->getErrors(), true)); $this->assertGreaterThan(0, $validator->numErrors()); if ([] !== $errors) { @@ -58,6 +58,7 @@ public function testInvalidCasesUsingAssoc($input, $schema, $checkMode = Constra if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); } + $checkMode |= Constraint::CHECK_MODE_STRICT; $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); @@ -88,6 +89,8 @@ public function testValidCases(string $input, string $schema, int $checkMode = C if ($this->validateSchema) { $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; } + $checkMode |= Constraint::CHECK_MODE_STRICT; + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schemaObject)); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); if (is_object($schema) && !isset($schema->{'$schema'})) { @@ -113,6 +116,7 @@ public function testValidCasesUsingAssoc($input, $schema, $checkMode = Constrain if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); } + $checkMode |= Constraint::CHECK_MODE_STRICT; $schema = json_decode($schema); $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schema), new UriResolver()); @@ -134,8 +138,8 @@ public function testValidCasesUsingAssoc($input, $schema, $checkMode = Constrain protected function getFilePaths(): array { return [ - realpath(__DIR__ . $this->relativeTestsRoot . '/draft6'), - realpath(__DIR__ . $this->relativeTestsRoot . '/draft6/optional') + realpath(__DIR__ . self::RELATIVE_TESTS_ROOT . '/draft6'), + realpath(__DIR__ . self::RELATIVE_TESTS_ROOT . '/draft6/optional') ]; } From 022342ad81febdf33bc59816bf3f8c35f6b1ef65 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Thu, 26 Jun 2025 21:03:39 +0200 Subject: [PATCH 03/21] feat: more support on draft-06 schema --- .../AdditionalPropertiesConstraint.php | 21 +++++++++- .../Drafts/Draft06/Draft06Constraint.php | 2 + .../Draft06/ExclusiveMaximumConstraint.php | 4 ++ .../Draft06/ExclusiveMinimumConstraint.php | 4 ++ .../Constraints/Drafts/Draft06/Factory.php | 2 + .../Drafts/Draft06/MaximumConstraint.php | 34 ++++++++++++++++ .../Drafts/Draft06/MultipleOfConstraint.php | 40 +++++++++++++++++++ .../Drafts/Draft06/TypeConstraint.php | 7 +++- 8 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index 3b8301f8..94c872c9 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -33,8 +33,25 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $additionalProperties = array_diff_key(get_object_vars($value), (array) $schema->properties); - // @todo additional properties should bechecked against the patternProperties + $additionalProperties = get_object_vars($value); + + if (isset($schema->properties)) { + $additionalProperties = array_diff_key($additionalProperties, (array)$schema->properties); + } + + if (isset($schema->patternProperties)) { + $patterns = array_keys(get_object_vars($schema->patternProperties)); + + foreach ($additionalProperties as $key => $_) { + foreach ($patterns as $pattern) { + if (preg_match("/{$pattern}/", $key)) { + unset($additionalProperties[$key]); + break; + } + } + } + } + if ($schema->additionalProperties === false && $additionalProperties !== []) { $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['additionalProperties' => array_keys($additionalProperties)]); } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index 19b19f17..2341a8a4 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -34,6 +34,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('minProperties', $value, $schema, $path, $i); $this->checkForKeyword('maxProperties', $value, $schema, $path, $i); $this->checkForKeyword('minimum', $value, $schema, $path, $i); + $this->checkForKeyword('maximum', $value, $schema, $path, $i); $this->checkForKeyword('minLength', $value, $schema, $path, $i); $this->checkForKeyword('exclusiveMinimum', $value, $schema, $path, $i); $this->checkForKeyword('maxItems', $value, $schema, $path, $i); @@ -41,6 +42,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('exclusiveMaximum', $value, $schema, $path, $i); $this->checkForKeyword('enum', $value, $schema, $path, $i); $this->checkForKeyword('const', $value, $schema, $path, $i); + $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); } protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php index 9694e4f8..2d29a175 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php @@ -25,6 +25,10 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + if (!is_numeric($value)) { + return; + } + if ($value < $schema->exclusiveMaximum) { return; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php index 1920b4d3..b167d198 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php @@ -25,6 +25,10 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + if (!is_numeric($value)) { + return; + } + if ($value > $schema->exclusiveMinimum) { return; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index f46445cf..c2806705 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -20,10 +20,12 @@ class Factory extends \JsonSchema\Constraints\Factory 'minProperties' => MinPropertiesConstraint::class, 'maxProperties' => MaxPropertiesConstraint::class, 'minimum' => MinimumConstraint::class, + 'maximum' => MaximumConstraint::class, 'exclusiveMinimum' => ExclusiveMinimumConstraint::class, 'minLength' => MinLengthConstraint::class, 'maxLength' => MaxLengthConstraint::class, 'maxItems' => MaxItemsConstraint::class, 'exclusiveMaximum' => ExclusiveMaximumConstraint::class, + 'multipleOf' => MultipleOfConstraint::class, ]; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php new file mode 100644 index 00000000..295a4ac0 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php @@ -0,0 +1,34 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maximum')) { + return; + } + + if ($value <= $schema->maximum) { + return; + } + + $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php new file mode 100644 index 00000000..4913b1f1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -0,0 +1,40 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'multipleOf')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if (fmod($value, $schema->multipleOf) === 0) { + return; + } + + $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); + + + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php index 4bcc6cc9..7b553ddb 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -26,7 +26,12 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $schemaTypes = (array) $schema->type; - $valueType = gettype($value); + $valueType = strtolower(gettype($value)); + if ($valueType === 'double' || $valueType === 'integer') { + $valueType = 'number'; + } + // @todo 1.0 is considered an integer but also number + foreach ($schemaTypes as $type) { if ($valueType === $type) { From d2c074d9374e7a40f94d7157c6413da8ff90e220 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 09:59:38 +0200 Subject: [PATCH 04/21] feat: Fixing multiple of; Removing dedicated draft-06 test case --- .../Drafts/Draft06/MaximumConstraint.php | 4 + .../Drafts/Draft06/MultipleOfConstraint.php | 2 +- tests/Drafts/Draft6Test.php | 150 ------------------ 3 files changed, 5 insertions(+), 151 deletions(-) delete mode 100644 tests/Drafts/Draft6Test.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php index 295a4ac0..bdd1db13 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php @@ -25,6 +25,10 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + if (!is_numeric($value)) { + return; + } + if ($value <= $schema->maximum) { return; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php index 4913b1f1..075555c2 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -29,7 +29,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - if (fmod($value, $schema->multipleOf) === 0) { + if (fmod($value, $schema->multipleOf) === 0.0) { return; } diff --git a/tests/Drafts/Draft6Test.php b/tests/Drafts/Draft6Test.php deleted file mode 100644 index 9678fee3..00000000 --- a/tests/Drafts/Draft6Test.php +++ /dev/null @@ -1,150 +0,0 @@ -validateSchema) { - $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; - } - $checkMode |= Constraint::CHECK_MODE_STRICT; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - - // add `$schema` if missing - if (is_object($schema) && !isset($schema->{'$schema'})) { - $schema->{'$schema'} = $this->schemaSpec; - } - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $checkValue = json_decode($input, false); - $errorMask = $validator->validate($checkValue, $schema); - - $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION), 'Document is invalid: ' .print_r($validator->getErrors(), true)); - $this->assertGreaterThan(0, $validator->numErrors()); - - if ([] !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); - } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); - } - - /** - * @dataProvider getInvalidForAssocTests - */ - public function testInvalidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST, $errors = []): void - { - $checkMode = $checkMode === null ? Constraint::CHECK_MODE_TYPE_CAST : $checkMode; - if ($this->validateSchema) { - $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; - } - if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { - $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); - } - $checkMode |= Constraint::CHECK_MODE_STRICT; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - if (is_object($schema) && !isset($schema->{'$schema'})) { - $schema->{'$schema'} = $this->schemaSpec; - } - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $checkValue = json_decode($input, true); - $errorMask = $validator->validate($checkValue, $schema); - - $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION)); - $this->assertGreaterThan(0, $validator->numErrors()); - - if ([] !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); - } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); - } - - /** - * @dataProvider getValidTests - */ - public function testValidCases(string $input, string $schema, int $checkMode = Constraint::CHECK_MODE_NORMAL): void - { - $schemaObject = json_decode($schema); - $checkMode |= Constraint::CHECK_MODE_STRICT; - if ($this->validateSchema) { - $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; - } - $checkMode |= Constraint::CHECK_MODE_STRICT; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schemaObject)); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - if (is_object($schema) && !isset($schema->{'$schema'})) { - $schema->{'$schema'} = $this->schemaSpec; - } - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $checkValue = json_decode($input, false); - $errorMask = $validator->validate($checkValue, $schema); - $this->assertEquals(0, $errorMask); - - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); - } - - /** - * @dataProvider getValidForAssocTests - */ - public function testValidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST): void - { - if ($this->validateSchema) { - $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; - } - if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { - $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); - } - $checkMode |= Constraint::CHECK_MODE_STRICT; - - $schema = json_decode($schema); - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schema), new UriResolver()); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - if (is_object($schema) && !isset($schema->{'$schema'})) { - $schema->{'$schema'} = $this->schemaSpec; - } - - $value = json_decode($input, true); - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - - $errorMask = $validator->validate($value, $schema); - $this->assertEquals(0, $errorMask, $this->validatorErrorsToString($validator)); - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); - } - /** - * {@inheritdoc} - */ - protected function getFilePaths(): array - { - return [ - realpath(__DIR__ . self::RELATIVE_TESTS_ROOT . '/draft6'), - realpath(__DIR__ . self::RELATIVE_TESTS_ROOT . '/draft6/optional') - ]; - } - - protected function getSkippedTests(): array - { - return []; - } -} From ff00ecc07c9440d302c4d8da43e821f03ede5a5f Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 10:09:35 +0200 Subject: [PATCH 05/21] fix: restore number constraint for draft-03 and draft-04 --- .../Constraints/NumberConstraint.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/JsonSchema/Constraints/NumberConstraint.php diff --git a/src/JsonSchema/Constraints/NumberConstraint.php b/src/JsonSchema/Constraints/NumberConstraint.php new file mode 100644 index 00000000..e1b2ffc3 --- /dev/null +++ b/src/JsonSchema/Constraints/NumberConstraint.php @@ -0,0 +1,84 @@ + + * @author Bruno Prieto Reis + */ +class NumberConstraint extends Constraint +{ + /** + * {@inheritdoc} + */ + public function check(&$element, $schema = null, ?JsonPointer $path = null, $i = null): void + { + // Verify minimum + if (isset($schema->exclusiveMinimum)) { + if (isset($schema->minimum)) { + if ($schema->exclusiveMinimum && $element <= $schema->minimum) { + $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, ['minimum' => $schema->minimum]); + } elseif ($element < $schema->minimum) { + $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum]); + } + } else { + $this->addError(ConstraintError::MISSING_MINIMUM(), $path); + } + } elseif (isset($schema->minimum) && $element < $schema->minimum) { + $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum]); + } + + // Verify maximum + if (isset($schema->exclusiveMaximum)) { + if (isset($schema->maximum)) { + if ($schema->exclusiveMaximum && $element >= $schema->maximum) { + $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, ['maximum' => $schema->maximum]); + } elseif ($element > $schema->maximum) { + $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum]); + } + } else { + $this->addError(ConstraintError::MISSING_MAXIMUM(), $path); + } + } elseif (isset($schema->maximum) && $element > $schema->maximum) { + $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum]); + } + + // Verify divisibleBy - Draft v3 + if (isset($schema->divisibleBy) && $this->fmod($element, $schema->divisibleBy) != 0) { + $this->addError(ConstraintError::DIVISIBLE_BY(), $path, ['divisibleBy' => $schema->divisibleBy]); + } + + // Verify multipleOf - Draft v4 + if (isset($schema->multipleOf) && $this->fmod($element, $schema->multipleOf) != 0) { + $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf]); + } + + $this->checkFormat($element, $schema, $path, $i); + } + + private function fmod($number1, $number2) + { + $modulus = ($number1 - round($number1 / $number2) * $number2); + $precision = 0.0000000001; + + if (-$precision < $modulus && $modulus < $precision) { + return 0.0; + } + + return $modulus; + } +} From 3e7a8c42824f2bf38e3323ba564676bd5907c331 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 11:15:18 +0200 Subject: [PATCH 06/21] feat: add more support for draft06 schema --- .../Drafts/Draft06/AnyOfConstraint.php | 47 +++++ .../Drafts/Draft06/Draft06Constraint.php | 5 +- .../Constraints/Drafts/Draft06/Factory.php | 4 + .../Drafts/Draft06/FormatConstraint.php | 171 ++++++++++++++++++ .../Drafts/Draft06/RequiredConstraint.php | 58 ++++++ .../Drafts/Draft06/TypeConstraint.php | 16 +- tests/JsonSchemaTestSuiteTest.php | 6 +- 7 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php new file mode 100644 index 00000000..272fbdcb --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php @@ -0,0 +1,47 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'anyOf')) { + return; + } + + foreach ($schema->anyOf as $anyOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + try { + $schemaConstraint->check($value, $anyOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + return; + } + } catch (ValidationException $e) {} + + $this->addError(ConstraintError::ANY_OF(), $path); + } + + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index 2341a8a4..6faa5acf 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -17,12 +17,12 @@ public function __construct() public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { // Apply defaults - // Required keyword + $this->checkForKeyword('required', $value, $schema, $path, $i); $this->checkForKeyword('type', $value, $schema, $path, $i); // Not $this->checkForKeyword('dependencies', $value, $schema, $path, $i); // allof - // anyof + $this->checkForKeyword('anyOf', $value, $schema, $path, $i); // oneof // array @@ -43,6 +43,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('enum', $value, $schema, $path, $i); $this->checkForKeyword('const', $value, $schema, $path, $i); $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); + $this->checkForKeyword('format', $value, $schema, $path, $i); } protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index c2806705..2cfff04c 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -10,6 +10,7 @@ class Factory extends \JsonSchema\Constraints\Factory * @var array */ protected $constraintMap = [ + 'schema' => Draft06Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, 'dependencies' => DependenciesConstraint::class, 'type' => TypeConstraint::class, @@ -27,5 +28,8 @@ class Factory extends \JsonSchema\Constraints\Factory 'maxItems' => MaxItemsConstraint::class, 'exclusiveMaximum' => ExclusiveMaximumConstraint::class, 'multipleOf' => MultipleOfConstraint::class, + 'required' => RequiredConstraint::class, + 'format' => FormatConstraint::class, + 'anyOf' => AnyOfConstraint::class, ]; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php new file mode 100644 index 00000000..cd56ff4c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -0,0 +1,171 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'format')) { + return; + } + + if (!is_string($value)) { + return; + } + + switch ($schema->format) { + case 'date': + if (!$this->validateDateTime($value, 'Y-m-d')) { + $this->addError(ConstraintError::FORMAT_DATE(), $path, ['date' => $value, 'format' => $schema->format]); + } + break; + case 'time': + if (!$this->validateDateTime($value, 'H:i:s')) { + $this->addError(ConstraintError::FORMAT_TIME(), $path, ['time' => $value, 'format' => $schema->format]); + } + break; + case 'date-time': + if (Rfc3339::createFromString($value) === null) { + $this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, ['dateTime' => $value, 'format' => $schema->format]); + } + break; + case 'utc-millisec': + if (!$this->validateDateTime($value, 'U')) { + $this->addError(ConstraintError::FORMAT_DATE_UTC(), $path, ['value' => $value, 'format' => $schema->format]); + } + break; + case 'regex': + if (!$this->validateRegex($value)) { + $this->addError(ConstraintError::FORMAT_REGEX(), $path, ['value' => $value, 'format' => $schema->format]); + } + break; + case 'ip-address': + case 'ipv4': + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV4) === null) { + $this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]); + } + break; + case 'ipv6': + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV6) === null) { + $this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]); + } + break; + case 'color': + if (!$this->validateColor($value)) { + $this->addError(ConstraintError::FORMAT_COLOR(), $path, ['format' => $schema->format]); + } + break; + case 'style': + if (!$this->validateStyle($value)) { + $this->addError(ConstraintError::FORMAT_STYLE(), $path, ['format' => $schema->format]); + } + break; + case 'phone': + if (!$this->validatePhone($value)) { + $this->addError(ConstraintError::FORMAT_PHONE(), $path, ['format' => $schema->format]); + } + break; + case 'uri': + if (!UriValidator::isValid($value)) { + $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); + } + break; + + case 'uriref': + case 'uri-reference': + if (!(UriValidator::isValid($value) || RelativeReferenceValidator::isValid($value))) { + $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); + } + break; + + case 'email': + if (filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE | FILTER_FLAG_EMAIL_UNICODE) === null) { + $this->addError(ConstraintError::FORMAT_EMAIL(), $path, ['format' => $schema->format]); + } + break; + case 'host-name': + case 'hostname': + if (!$this->validateHostname($value)) { + $this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, ['format' => $schema->format]); + } + break; + default: + break; + } + + } + + private function validateDateTime(string $datetime, string $format): bool + { + $dt = \DateTime::createFromFormat($format, $datetime); + + if (!$dt) { + return false; + } + + return $datetime === $dt->format($format); + } + + private function validateRegex(string $regex): bool + { + return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; + } + + /** + * Transform a JSON pattern into a PCRE regex + */ + private static function jsonPatternToPhpRegex(string $pattern): string + { + return '~' . str_replace('~', '\\~', $pattern) . '~u'; + } + + private function validateColor(string $color): bool + { + if (in_array(strtolower($color), ['aqua', 'black', 'blue', 'fuchsia', + 'gray', 'green', 'lime', 'maroon', 'navy', 'olive', 'orange', 'purple', + 'red', 'silver', 'teal', 'white', 'yellow'])) { + return true; + } + + return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) !== false; + } + + private function validateStyle(string $style): bool + { + $properties = explode(';', rtrim($style, ';')); + $invalidEntries = preg_grep('/^\s*[-a-z]+\s*:\s*.+$/i', $properties, PREG_GREP_INVERT); + + return empty($invalidEntries); + } + + private function validatePhone($phone): bool + { + return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false; + } + + private function validateHostname(string $host): bool + { + $hostnameRegex = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/i'; + + return preg_match($hostnameRegex, $host) !== false; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php new file mode 100644 index 00000000..c21a1d4c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php @@ -0,0 +1,58 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'required')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->required as $required) { + if (isset($value->{$required})) { + continue; + } + + $this->addError(ConstraintError::REQUIRED(), $this->incrementPath($path, $required), ['property' => $required]); + } + } + + /** + * @todo refactor as this was only copied from UndefinedConstraint + * Bubble down the path + * + * @param JsonPointer|null $path Current path + * @param mixed $i What to append to the path + */ + protected function incrementPath(?JsonPointer $path, $i): JsonPointer + { + $path = $path ?? new JsonPointer(''); + + if ($i === null || $i === '') { + return $path; + } + + return $path->withPropertyPaths(array_merge($path->getPropertyPaths(), [$i])); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php index 7b553ddb..0c8f1646 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -27,16 +27,22 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $schemaTypes = (array) $schema->type; $valueType = strtolower(gettype($value)); - if ($valueType === 'double' || $valueType === 'integer') { - $valueType = 'number'; - } - // @todo 1.0 is considered an integer but also number - + // All specific number types are a number + $valueIsNumber = $valueType === 'double' || $valueType === 'integer'; + // A float with zero fractional part is an integer + $isInteger = $valueIsNumber && fmod($value, 1.0) === 0.0; foreach ($schemaTypes as $type) { if ($valueType === $type) { return; } + + if ($type === 'number' && $valueIsNumber) { + return; + } + if ($type === 'integer' && $isInteger) { + return; + } } $this->addError(ConstraintError::TYPE(), $path, ['found' => $valueType, 'expected' => implode(', ', $schemaTypes)]); diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 953f9183..3475e796 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -19,19 +19,21 @@ class JsonSchemaTestSuiteTest extends TestCase /** * @dataProvider casesDataProvider * + * @param \stdClass|bool $schema * @param mixed $data */ public function testTestCaseValidatesCorrectly( string $testCaseDescription, string $testDescription, - \stdClass $schema, + $schema, $data, bool $expectedValidationResult, bool $optional ): void { $schemaStorage = new SchemaStorage(); - $schemaStorage->addSchema(property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI, $schema); + $id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; + $schemaStorage->addSchema($id, $schema); $this->loadRemotesIntoStorage($schemaStorage); $validator = new Validator(new Factory($schemaStorage)); From e1a6b90d0935297a6c20763f7dcdaf19754c0bf0 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 13:36:24 +0200 Subject: [PATCH 07/21] feat: add more support for draft 06 --- src/JsonSchema/ConstraintError.php | 6 ++ .../Draft06/AdditionalItemsConstraint.php | 44 +++++++++++++ .../AdditionalPropertiesConstraint.php | 21 ++++-- .../Drafts/Draft06/AnyOfConstraint.php | 4 +- .../Drafts/Draft06/ContainsConstraint.php | 49 ++++++++++++++ .../Drafts/Draft06/Draft06Constraint.php | 16 ++++- .../Constraints/Drafts/Draft06/Factory.php | 5 ++ .../Drafts/Draft06/ItemsConstraint.php | 53 +++++++++++++++ .../Draft06/PatternPropertiesConstraint.php | 48 ++++++++++++++ .../Draft06/PropertiesNamesConstraint.php | 64 +++++++++++++++++++ src/JsonSchema/Entity/ErrorBag.php | 6 ++ src/JsonSchema/Entity/ErrorBagProxy.php | 5 ++ src/JsonSchema/Validator.php | 5 +- tests/JsonSchemaTestSuiteTest.php | 6 +- 14 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index c17cfeff..d781fa1f 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -17,8 +17,10 @@ class ConstraintError extends Enum public const DIVISIBLE_BY = 'divisibleBy'; public const ENUM = 'enum'; public const CONSTANT = 'const'; + public const CONTAINS = 'contains'; public const EXCLUSIVE_MINIMUM = 'exclusiveMinimum'; public const EXCLUSIVE_MAXIMUM = 'exclusiveMaximum'; + public const FALSE = 'false'; public const FORMAT_COLOR = 'colorFormat'; public const FORMAT_DATE = 'dateFormat'; public const FORMAT_DATE_TIME = 'dateTimeFormat'; @@ -51,6 +53,7 @@ class ConstraintError extends Enum public const PREGEX_INVALID = 'pregrex'; public const PROPERTIES_MIN = 'minProperties'; public const PROPERTIES_MAX = 'maxProperties'; + public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; @@ -70,8 +73,10 @@ public function getMessage() self::DIVISIBLE_BY => 'Is not divisible by %d', self::ENUM => 'Does not have a value in the enumeration %s', self::CONSTANT => 'Does not have a value equal to %s', + self::CONTAINS => 'Does not have a value valid to contains schema', self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %d', self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %d', + self::FALSE => 'Boolean schema false', self::FORMAT_COLOR => 'Invalid color', self::FORMAT_DATE => 'Invalid date %s, expected format YYYY-MM-DD', self::FORMAT_DATE_TIME => 'Invalid date-time %s, expected format YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DDThh:mm:ss+hh:mm', @@ -104,6 +109,7 @@ public function getMessage() self::PREGEX_INVALID => 'The pattern %s is invalid', self::PROPERTIES_MIN => 'Must contain a minimum of %d properties', self::PROPERTIES_MAX => 'Must contain no more than %d properties', + self::PROPERTY_NAMES => 'Property name %s is invalid', self::TYPE => '%s value found, but %s is required', self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array' ]; diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php new file mode 100644 index 00000000..fd950754 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -0,0 +1,44 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'additionalItems')) { + return; + } + + if ($schema->additionalItems === true) { + return; + } + + if (!is_array($value)) { + return; + } + + $additionalItems = array_diff_key($value, $schema->items); + + foreach ($additionalItems as $key => $_) { + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $key, 'additionalItems' => $schema->additionalItems]); + } + + + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index 94c872c9..955a0ead 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -6,7 +6,6 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -14,9 +13,13 @@ class AdditionalPropertiesConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void @@ -52,8 +55,18 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } - if ($schema->additionalProperties === false && $additionalProperties !== []) { - $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['additionalProperties' => array_keys($additionalProperties)]); + if (is_object($schema->additionalProperties)) { + foreach ($additionalProperties as $key => $additionalPropertiesValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($additionalPropertiesValue, $schema->additionalProperties, $path, $i); // @todo increment path + if ($schemaConstraint->isValid()) { + unset($additionalProperties[$key]); + } + } + } + + foreach ($additionalProperties as $key => $additionalPropertiesValue) { + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $additionalPropertiesValue]); } } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php index 272fbdcb..aa7210df 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php @@ -39,9 +39,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } } catch (ValidationException $e) {} - - $this->addError(ConstraintError::ANY_OF(), $path); } + $this->addError(ConstraintError::ANY_OF(), $path); + } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php new file mode 100644 index 00000000..018d362e --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php @@ -0,0 +1,49 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'contains')) { + return; + } + + $properties = []; + if (is_array($value)) { + $properties = $value; + } + if (is_object($value)) { + $properties = get_object_vars($value); + } + + foreach ($properties as $propertyName => $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + $schemaConstraint->check($propertyValue, $schema->contains, $path, $i); + if ($schemaConstraint->isValid()) { + return; + } + } + + $this->addError(ConstraintError::CONTAINS(), $path, ['contains' => $schema->contains]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index 6faa5acf..ca9ed6ad 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -4,6 +4,7 @@ namespace JsonSchema\Constraints\Drafts\Draft06; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\Constraint; use JsonSchema\Entity\JsonPointer; @@ -16,8 +17,18 @@ public function __construct() public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { + if (is_bool($schema)) { + if ($schema === false) { + $this->addError(ConstraintError::FALSE(), $path, []); + } + return; + } + // Apply defaults $this->checkForKeyword('required', $value, $schema, $path, $i); + $this->checkForKeyword('contains', $value, $schema, $path, $i); + $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); + $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); $this->checkForKeyword('type', $value, $schema, $path, $i); // Not $this->checkForKeyword('dependencies', $value, $schema, $path, $i); @@ -25,10 +36,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('anyOf', $value, $schema, $path, $i); // oneof - // array - // object - // string $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); + $this->checkForKeyword('items', $value, $schema, $path, $i); + $this->checkForKeyword('additionalItems', $value, $schema, $path, $i); $this->checkForKeyword('uniqueItems', $value, $schema, $path, $i); $this->checkForKeyword('minItems', $value, $schema, $path, $i); $this->checkForKeyword('minProperties', $value, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index 2cfff04c..a3d20b61 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -12,6 +12,7 @@ class Factory extends \JsonSchema\Constraints\Factory protected $constraintMap = [ 'schema' => Draft06Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'additionalItems' => AdditionalItemsConstraint::class, 'dependencies' => DependenciesConstraint::class, 'type' => TypeConstraint::class, 'const' => ConstConstraint::class, @@ -31,5 +32,9 @@ class Factory extends \JsonSchema\Constraints\Factory 'required' => RequiredConstraint::class, 'format' => FormatConstraint::class, 'anyOf' => AnyOfConstraint::class, + 'contains' => ContainsConstraint::class, + 'propertyNames' => PropertiesNamesConstraint::class, + 'patternProperties' => PatternPropertiesConstraint::class, + 'items' => ItemsConstraint::class, ]; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php new file mode 100644 index 00000000..4f16d914 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -0,0 +1,53 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'items')) { + return; + } + + $properties = []; + if (is_object($value)) { + $properties = get_object_vars($value); + } + if (is_array($value)) { + $properties = $value; + } + if (is_object($schema->items)) { + foreach ($properties as $propertyName => $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $schema->items, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php new file mode 100644 index 00000000..3100a95c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -0,0 +1,48 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'patternProperties')) { + return; + } + + $properties = get_object_vars($value); + + + foreach ($properties as $propertyName => $propertyValue) { + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { + if (preg_match('/' . str_replace('/', '\/', $patternPropertyRegex) . '/', $propertyName)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue 2; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php new file mode 100644 index 00000000..a1338bff --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php @@ -0,0 +1,64 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'propertyNames')) { + return; + } + + if (!is_object($value)) { + return; + } + if ($schema->propertyNames === true) { + return; + } + + $propertyNames = get_object_vars($value); + + if ($schema->propertyNames === false) { + foreach ($propertyNames as $propertyName => $_) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'false', 'name' => $propertyName]); + } + return; + } + + if (property_exists($schema->propertyNames, "maxLength")) { + foreach ($propertyNames as $propertyName => $_) { + $length = mb_strlen($propertyName); + if ($length > $schema->propertyNames->maxLength) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'maxLength', 'length' => $length, 'name' => $propertyName]); + } + } + } + + if (property_exists($schema->propertyNames, "pattern")) { + foreach ($propertyNames as $propertyName => $_) { + if (!preg_match('/' . str_replace('/', '\/', $schema->propertyNames->pattern) . '/', $propertyName)) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'pattern', 'name' => $propertyName]); + } + } + } + } +} diff --git a/src/JsonSchema/Entity/ErrorBag.php b/src/JsonSchema/Entity/ErrorBag.php index b2eaee90..9122b12e 100644 --- a/src/JsonSchema/Entity/ErrorBag.php +++ b/src/JsonSchema/Entity/ErrorBag.php @@ -39,6 +39,12 @@ public function __construct(Factory $factory) $this->factory = $factory; } + public function reset(): void + { + $this->errors = []; + $this->errorMask = Validator::ERROR_NONE; + } + /** @return ErrorList */ public function getErrors(): array { diff --git a/src/JsonSchema/Entity/ErrorBagProxy.php b/src/JsonSchema/Entity/ErrorBagProxy.php index 17deba33..9dcc16a1 100644 --- a/src/JsonSchema/Entity/ErrorBagProxy.php +++ b/src/JsonSchema/Entity/ErrorBagProxy.php @@ -58,4 +58,9 @@ protected function errorBag(): ErrorBag return $this->errorBag; } + + public function __clone() + { + $this->errorBag->reset(); + } } diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index f14aa3fc..569c7aad 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -68,7 +68,10 @@ public function validate(&$value, $schema = null, ?int $checkMode = null): int // Boolean schema requires no further validation if (is_bool($schema)) { - return $validator->getErrorMask(); + if ($schema === false) { + $this->addError(ConstraintError::FALSE()); + } + return $this->getErrorMask(); } if ($this->factory->getConfig(Constraint::CHECK_MODE_STRICT)) { diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 3475e796..b442a8c5 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -51,7 +51,11 @@ public function testTestCaseValidatesCorrectly( $this->markTestSkipped('Optional test case would fail'); } - self::assertEquals($expectedValidationResult, count($validator->getErrors()) === 0); + self::assertEquals( + $expectedValidationResult, + count($validator->getErrors()) === 0, + $expectedValidationResult ? print_r($validator->getErrors(), true) : 'Validator returned valid but the testcase indicates it is invalid' + ); } public function casesDataProvider(): \Generator From e09fed3bd3545a0730529c2cece70d31af0e1945 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 20:36:43 +0200 Subject: [PATCH 08/21] feat: add more draft-06 support --- .../Draft06/AdditionalItemsConstraint.php | 1 + .../Drafts/Draft06/AllOfConstraint.php | 43 +++++++++++++++ .../Drafts/Draft06/ContainsConstraint.php | 9 ++-- .../Drafts/Draft06/DependenciesConstraint.php | 10 ++++ .../Drafts/Draft06/Draft06Constraint.php | 7 +-- .../Constraints/Drafts/Draft06/Factory.php | 4 ++ .../Drafts/Draft06/ItemsConstraint.php | 27 +++++----- .../Drafts/Draft06/MultipleOfConstraint.php | 12 ++++- .../Drafts/Draft06/NotConstraint.php | 43 +++++++++++++++ .../Drafts/Draft06/OneOfConstraint.php | 51 ++++++++++++++++++ .../Draft06/PatternPropertiesConstraint.php | 7 ++- .../Drafts/Draft06/PropertiesConstraint.php | 52 +++++++++++++++++++ .../Drafts/Draft06/RequiredConstraint.php | 2 +- 13 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php index fd950754..1389dcfe 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -33,6 +33,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + $additionalItems = array_diff_key($value, $schema->items); foreach ($additionalItems as $key => $_) { diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php new file mode 100644 index 00000000..fdcaa5db --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php @@ -0,0 +1,43 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'allOf')) { + return; + } + + foreach ($schema->allOf as $allOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $allOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + continue; + } + $this->addError(ConstraintError::ALL_OF(), $path); + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php index 018d362e..af2b20ba 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php @@ -28,14 +28,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $properties = []; - if (is_array($value)) { - $properties = $value; - } - if (is_object($value)) { - $properties = get_object_vars($value); + if (!is_array($value)) { + return; } - foreach ($properties as $propertyName => $propertyValue) { + foreach ($value as $propertyName => $propertyValue) { $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($propertyValue, $schema->contains, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php index 8a9a903f..3f0ecded 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php @@ -30,6 +30,16 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } foreach ($schema->dependencies as $dependant => $dependencies) { + if (!property_exists($value, $dependant)) { + continue; + } + if ($dependencies === true) { + continue; + } + if ($dependencies === false) { + $this->addError(ConstraintError::FALSE(), $path, ['dependant' => $dependant]); + continue; + } foreach ($dependencies as $dependency) { if (property_exists($value, $dependant) && !property_exists($value, $dependency)) { $this->addError(ConstraintError::DEPENDENCIES(), $path, ['dependant' => $dependant, 'dependency' => $dependency]); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index ca9ed6ad..d0cd228e 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -27,14 +27,15 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n // Apply defaults $this->checkForKeyword('required', $value, $schema, $path, $i); $this->checkForKeyword('contains', $value, $schema, $path, $i); + $this->checkForKeyword('properties', $value, $schema, $path, $i); $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); $this->checkForKeyword('type', $value, $schema, $path, $i); - // Not + $this->checkForKeyword('not', $value, $schema, $path, $i); $this->checkForKeyword('dependencies', $value, $schema, $path, $i); - // allof + $this->checkForKeyword('allOf', $value, $schema, $path, $i); $this->checkForKeyword('anyOf', $value, $schema, $path, $i); - // oneof + $this->checkForKeyword('oneOf', $value, $schema, $path, $i); $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); $this->checkForKeyword('items', $value, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index a3d20b61..ae821d90 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -32,9 +32,13 @@ class Factory extends \JsonSchema\Constraints\Factory 'required' => RequiredConstraint::class, 'format' => FormatConstraint::class, 'anyOf' => AnyOfConstraint::class, + 'allOf' => AllOfConstraint::class, + 'oneOf' => OneOfConstraint::class, + 'not' => NotConstraint::class, 'contains' => ContainsConstraint::class, 'propertyNames' => PropertiesNamesConstraint::class, 'patternProperties' => PatternPropertiesConstraint::class, + 'properties' => PropertiesConstraint::class, 'items' => ItemsConstraint::class, ]; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php index 4f16d914..13116d1a 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -31,23 +31,26 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $properties = []; - if (is_object($value)) { - $properties = get_object_vars($value); - } - if (is_array($value)) { - $properties = $value; + if (!is_array($value)) { + return; } - if (is_object($schema->items)) { - foreach ($properties as $propertyName => $propertyValue) { - $schemaConstraint = $this->factory->createInstanceFor('schema'); - $schemaConstraint->check($propertyValue, $schema->items, $path, $i); - if ($schemaConstraint->isValid()) { + + foreach ($value as $propertyName => $propertyValue) { + $itemSchema = $schema->items; + if (is_array($itemSchema)) { + if (!array_key_exists($propertyName, $itemSchema)) { continue; } - $this->addErrors($schemaConstraint->getErrors()); + $itemSchema = $itemSchema[$propertyName]; } + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $itemSchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); } } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php index 075555c2..b4b0100f 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -29,7 +29,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - if (fmod($value, $schema->multipleOf) === 0.0) { + if (fmod($value, $schema->multipleOf) === 0.0 || $this->isMultipleOf((string) $value, (string) $schema->multipleOf)) { return; } @@ -37,4 +37,14 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } + + private function isMultipleOf(string $value, string $multipleOf): bool + { + if (bccomp($multipleOf, '0', 20) === 0) { + return false; + } + + $div = bcdiv($value, $multipleOf, 20); + return bccomp(bcmod($div, '1', 20), '0', 20) === 0; + } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php new file mode 100644 index 00000000..739aa3d0 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php @@ -0,0 +1,43 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'not')) { + return; + } + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $schema->not, $path, $i); + + if (! $schemaConstraint->isValid()) { + return; + } + + $this->addError(ConstraintError::NOT(), $path); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php new file mode 100644 index 00000000..ed840bbb --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php @@ -0,0 +1,51 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'oneOf')) { + return; + } + + $matchedSchema = 0; + foreach ($schema->oneOf as $oneOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $oneOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + $matchedSchema++; + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + + if ($matchedSchema !== 1) { + $this->addError(ConstraintError::ONE_OF(), $path); + } else { + $this->errorBag()->reset(); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php index 3100a95c..ce368a98 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -28,8 +28,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $properties = get_object_vars($value); + if (!is_object($value)) { + return; + } + $properties = get_object_vars($value); foreach ($properties as $propertyName => $propertyValue) { foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { @@ -37,7 +40,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); if ($schemaConstraint->isValid()) { - continue 2; + continue; } $this->addErrors($schemaConstraint->getErrors()); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php new file mode 100644 index 00000000..4eeec190 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php @@ -0,0 +1,52 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'properties')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->properties as $propertyName => $propertySchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + if (! property_exists($value, $propertyName)) { + continue; + } + + $schemaConstraint->check($value->{$propertyName}, $propertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php index c21a1d4c..9e56ef22 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php @@ -30,7 +30,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } foreach ($schema->required as $required) { - if (isset($value->{$required})) { + if (property_exists($value, $required)) { continue; } From 29b5eaf3c91498c3c0f7f537382e080dc0860fb4 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 21:00:09 +0200 Subject: [PATCH 09/21] feat: more support for draft-06 --- .../Draft06/AdditionalItemsConstraint.php | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php index 1389dcfe..b0b7e617 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -14,9 +14,13 @@ class AdditionalItemsConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void @@ -28,16 +32,31 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if ($schema->additionalItems === true) { return; } + if ($schema->additionalItems === false && ! property_exists($schema, 'items')) { + return; + } if (!is_array($value)) { return; } + if (!property_exists($schema, 'items')) { + return; + } + if (property_exists($schema, 'items') && is_object($schema->items)) { + return; + } + + $additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []); + foreach ($additionalItems as $propertyName => $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $schema->additionalItems, $path, $i); - $additionalItems = array_diff_key($value, $schema->items); + if ($schemaConstraint->isValid()) { + continue; + } - foreach ($additionalItems as $key => $_) { - $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $key, 'additionalItems' => $schema->additionalItems]); + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); } From a31915857ac50073f01e43c89656ab4afb1aaf25 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 21:08:07 +0200 Subject: [PATCH 10/21] style: correct code style violations --- .../Drafts/Draft06/AdditionalItemsConstraint.php | 4 +--- .../Drafts/Draft06/AdditionalPropertiesConstraint.php | 2 +- .../Constraints/Drafts/Draft06/AllOfConstraint.php | 1 - .../Constraints/Drafts/Draft06/AnyOfConstraint.php | 4 ++-- .../Constraints/Drafts/Draft06/ContainsConstraint.php | 1 + .../Constraints/Drafts/Draft06/Draft06Constraint.php | 1 + .../Constraints/Drafts/Draft06/EnumConstraint.php | 1 - .../Constraints/Drafts/Draft06/FormatConstraint.php | 1 - .../Constraints/Drafts/Draft06/ItemsConstraint.php | 5 +---- .../Constraints/Drafts/Draft06/MultipleOfConstraint.php | 3 +-- .../Constraints/Drafts/Draft06/NotConstraint.php | 6 ++---- .../Constraints/Drafts/Draft06/OneOfConstraint.php | 1 - .../Drafts/Draft06/PatternPropertiesConstraint.php | 1 - .../Constraints/Drafts/Draft06/PropertiesConstraint.php | 7 ++----- .../Drafts/Draft06/PropertiesNamesConstraint.php | 5 +++-- .../Constraints/Drafts/Draft06/TypeConstraint.php | 4 ++-- .../Constraints/Drafts/Draft06/UniqueItemsConstraint.php | 1 + src/JsonSchema/Entity/ErrorBag.php | 2 +- src/JsonSchema/Validator.php | 1 + tests/Constraints/VeryBaseTestCase.php | 1 + tests/JsonSchemaTestSuiteTest.php | 7 ++----- 21 files changed, 23 insertions(+), 36 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php index b0b7e617..7602ba5d 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -32,7 +32,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if ($schema->additionalItems === true) { return; } - if ($schema->additionalItems === false && ! property_exists($schema, 'items')) { + if ($schema->additionalItems === false && !property_exists($schema, 'items')) { return; } @@ -58,7 +58,5 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); } - - } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index 955a0ead..c2fa41e6 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -39,7 +39,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $additionalProperties = get_object_vars($value); if (isset($schema->properties)) { - $additionalProperties = array_diff_key($additionalProperties, (array)$schema->properties); + $additionalProperties = array_diff_key($additionalProperties, (array) $schema->properties); } if (isset($schema->patternProperties)) { diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php index fdcaa5db..5a69f657 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php @@ -8,7 +8,6 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -use JsonSchema\Exception\ValidationException; class AllOfConstraint implements ConstraintInterface { diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php index aa7210df..f6228454 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php @@ -38,10 +38,10 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if ($schemaConstraint->isValid()) { return; } - } catch (ValidationException $e) {} + } catch (ValidationException $e) { + } } $this->addError(ConstraintError::ANY_OF(), $path); - } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php index af2b20ba..45f0785d 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php @@ -15,6 +15,7 @@ class ContainsConstraint implements ConstraintInterface /** @var Factory */ private $factory; + public function __construct(?Factory $factory = null) { $this->factory = $factory ?: new Factory(); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index d0cd228e..cf0073f1 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -21,6 +21,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if ($schema === false) { $this->addError(ConstraintError::FALSE(), $path, []); } + return; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php index b83af1c9..1ed3b65a 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php @@ -7,7 +7,6 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; -use JsonSchema\Constraints\UndefinedConstraint; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; use JsonSchema\Tool\DeepComparer; diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index cd56ff4c..e3d91611 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -111,7 +111,6 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n default: break; } - } private function validateDateTime(string $datetime, string $format): bool diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php index 13116d1a..992ddc5e 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -4,14 +4,10 @@ namespace JsonSchema\Constraints\Drafts\Draft06; -use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -use JsonSchema\Rfc3339; -use JsonSchema\Tool\Validator\RelativeReferenceValidator; -use JsonSchema\Tool\Validator\UriValidator; class ItemsConstraint implements ConstraintInterface { @@ -19,6 +15,7 @@ class ItemsConstraint implements ConstraintInterface /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ private $factory; + public function __construct(?Factory $factory = null) { $this->factory = $factory ?: new Factory(); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php index b4b0100f..e77ef01c 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -34,8 +34,6 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); - - } private function isMultipleOf(string $value, string $multipleOf): bool @@ -45,6 +43,7 @@ private function isMultipleOf(string $value, string $multipleOf): bool } $div = bcdiv($value, $multipleOf, 20); + return bccomp(bcmod($div, '1', 20), '0', 20) === 0; } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php index 739aa3d0..20c4b154 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php @@ -9,9 +9,6 @@ use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -use JsonSchema\Rfc3339; -use JsonSchema\Tool\Validator\RelativeReferenceValidator; -use JsonSchema\Tool\Validator\UriValidator; class NotConstraint implements ConstraintInterface { @@ -19,6 +16,7 @@ class NotConstraint implements ConstraintInterface /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ private $factory; + public function __construct(?Factory $factory = null) { $this->factory = $factory ?: new Factory(); @@ -34,7 +32,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($value, $schema->not, $path, $i); - if (! $schemaConstraint->isValid()) { + if (!$schemaConstraint->isValid()) { return; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php index ed840bbb..cd8efd94 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php @@ -8,7 +8,6 @@ use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -use JsonSchema\Exception\ValidationException; class OneOfConstraint implements ConstraintInterface { diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php index ce368a98..3c74abb2 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -4,7 +4,6 @@ namespace JsonSchema\Constraints\Drafts\Draft06; -use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php index 4eeec190..7219d0ff 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php @@ -4,14 +4,10 @@ namespace JsonSchema\Constraints\Drafts\Draft06; -use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; -use JsonSchema\Rfc3339; -use JsonSchema\Tool\Validator\RelativeReferenceValidator; -use JsonSchema\Tool\Validator\UriValidator; class PropertiesConstraint implements ConstraintInterface { @@ -19,6 +15,7 @@ class PropertiesConstraint implements ConstraintInterface /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ private $factory; + public function __construct(?Factory $factory = null) { $this->factory = $factory ?: new Factory(); @@ -37,7 +34,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($schema->properties as $propertyName => $propertySchema) { $schemaConstraint = $this->factory->createInstanceFor('schema'); - if (! property_exists($value, $propertyName)) { + if (!property_exists($value, $propertyName)) { continue; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php index a1338bff..d621ecf2 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php @@ -41,10 +41,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($propertyNames as $propertyName => $_) { $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'false', 'name' => $propertyName]); } + return; } - if (property_exists($schema->propertyNames, "maxLength")) { + if (property_exists($schema->propertyNames, 'maxLength')) { foreach ($propertyNames as $propertyName => $_) { $length = mb_strlen($propertyName); if ($length > $schema->propertyNames->maxLength) { @@ -53,7 +54,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } - if (property_exists($schema->propertyNames, "pattern")) { + if (property_exists($schema->propertyNames, 'pattern')) { foreach ($propertyNames as $propertyName => $_) { if (!preg_match('/' . str_replace('/', '\/', $schema->propertyNames->pattern) . '/', $propertyName)) { $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'pattern', 'name' => $propertyName]); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php index 0c8f1646..531a4f95 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -25,7 +25,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - $schemaTypes = (array) $schema->type; + $schemaTypes = (array) $schema->type; $valueType = strtolower(gettype($value)); // All specific number types are a number $valueIsNumber = $valueType === 'double' || $valueType === 'integer'; @@ -40,7 +40,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if ($type === 'number' && $valueIsNumber) { return; } - if ($type === 'integer' && $isInteger) { + if ($type === 'integer' && $isInteger) { return; } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php index 75a21c15..1f1c4b9e 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php @@ -36,6 +36,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n for ($y = $x + 1; $y < $count; $y++) { if (DeepComparer::isEqual($value[$x], $value[$y])) { $this->addError(ConstraintError::UNIQUE_ITEMS(), $path); + return; } } diff --git a/src/JsonSchema/Entity/ErrorBag.php b/src/JsonSchema/Entity/ErrorBag.php index 9122b12e..9f87633b 100644 --- a/src/JsonSchema/Entity/ErrorBag.php +++ b/src/JsonSchema/Entity/ErrorBag.php @@ -84,7 +84,7 @@ public function addError(ConstraintError $constraint, ?JsonPointer $path = null, /** @param ErrorList $errors */ public function addErrors(array $errors): void { - if (! $errors) { + if (!$errors) { return; } diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index 569c7aad..d3083b2d 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -71,6 +71,7 @@ public function validate(&$value, $schema = null, ?int $checkMode = null): int if ($schema === false) { $this->addError(ConstraintError::FALSE()); } + return $this->getErrorMask(); } diff --git a/tests/Constraints/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index 5ba4ee71..b90dc63f 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -19,6 +19,7 @@ abstract class VeryBaseTestCase extends TestCase /** * @param object|bool|null $schema + * * @return object */ protected function getUriRetrieverMock($schema): object diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index b442a8c5..6fc1c440 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -20,7 +20,7 @@ class JsonSchemaTestSuiteTest extends TestCase * @dataProvider casesDataProvider * * @param \stdClass|bool $schema - * @param mixed $data + * @param mixed $data */ public function testTestCaseValidatesCorrectly( string $testCaseDescription, @@ -29,8 +29,7 @@ public function testTestCaseValidatesCorrectly( $data, bool $expectedValidationResult, bool $optional - ): void - { + ): void { $schemaStorage = new SchemaStorage(); $id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; $schemaStorage->addSchema($id, $schema); @@ -110,7 +109,6 @@ function ($file) { 'optional' => str_contains($file->getPathname(), '/optional/') ]; } - } } } @@ -163,5 +161,4 @@ private function is32Bit(): bool { return PHP_INT_SIZE === 4; } - } From 376709c7ce14482662b9ab52965ef46552899626 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 21:16:52 +0200 Subject: [PATCH 11/21] refactor: resolve phpstan found issues --- .../Drafts/Draft06/AdditionalItemsConstraint.php | 3 +-- .../Constraints/Drafts/Draft06/Draft06Constraint.php | 5 +++++ .../Constraints/Drafts/Draft06/FormatConstraint.php | 2 +- .../Constraints/Drafts/Draft06/ItemsConstraint.php | 3 +-- .../Constraints/Drafts/Draft06/NotConstraint.php | 3 +-- .../Drafts/Draft06/PropertiesConstraint.php | 3 +-- src/JsonSchema/Entity/ErrorBagProxy.php | 12 ++++++------ 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php index 7602ba5d..d1adbce8 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -6,7 +6,6 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -14,7 +13,7 @@ class AdditionalItemsConstraint implements ConstraintInterface { use ErrorBagProxy; - /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ + /** @var Factory */ private $factory; public function __construct(?Factory $factory = null) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index cf0073f1..fc83894d 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -58,6 +58,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('format', $value, $schema, $path, $i); } + /** + * @param mixed $value + * @param mixed $schema + * @param mixed $i + */ protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void { $validator = $this->factory->createInstanceFor($keyword); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index e3d91611..392fbe80 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -156,7 +156,7 @@ private function validateStyle(string $style): bool return empty($invalidEntries); } - private function validatePhone($phone): bool + private function validatePhone(string $phone): bool { return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php index 992ddc5e..21259e1a 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -5,7 +5,6 @@ namespace JsonSchema\Constraints\Drafts\Draft06; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -13,7 +12,7 @@ class ItemsConstraint implements ConstraintInterface { use ErrorBagProxy; - /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ + /** @var Factory */ private $factory; public function __construct(?Factory $factory = null) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php index 20c4b154..2a8268e1 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php @@ -6,7 +6,6 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -14,7 +13,7 @@ class NotConstraint implements ConstraintInterface { use ErrorBagProxy; - /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ + /** @var Factory */ private $factory; public function __construct(?Factory $factory = null) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php index 7219d0ff..4a9c4808 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php @@ -5,7 +5,6 @@ namespace JsonSchema\Constraints\Drafts\Draft06; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -13,7 +12,7 @@ class PropertiesConstraint implements ConstraintInterface { use ErrorBagProxy; - /** @var \JsonSchema\Constraints\Drafts\Draft06\Factory */ + /** @var Factory */ private $factory; public function __construct(?Factory $factory = null) diff --git a/src/JsonSchema/Entity/ErrorBagProxy.php b/src/JsonSchema/Entity/ErrorBagProxy.php index 9dcc16a1..e9d58412 100644 --- a/src/JsonSchema/Entity/ErrorBagProxy.php +++ b/src/JsonSchema/Entity/ErrorBagProxy.php @@ -13,8 +13,8 @@ */ trait ErrorBagProxy { - /** @var ErrorBag */ - protected $errorBag; + /** @var ?ErrorBag */ + protected $errorBag = null; /** @return ErrorList */ public function getErrors(): array @@ -29,7 +29,7 @@ public function addErrors(array $errors): void } /** - * @param array $more more array elements to add to the error + * @param array $more more array elements to add to the error */ public function addError(ConstraintError $constraint, ?JsonPointer $path = null, array $more = []): void { @@ -43,7 +43,7 @@ public function isValid(): bool protected function initialiseErrorBag(Factory $factory): ErrorBag { - if (!isset($this->errorBag)) { + if (is_null($this->errorBag)) { $this->errorBag = new ErrorBag($factory); } @@ -52,7 +52,7 @@ protected function initialiseErrorBag(Factory $factory): ErrorBag protected function errorBag(): ErrorBag { - if (!isset($this->errorBag)) { + if (is_null($this->errorBag)) { throw new \RuntimeException('ErrorBag not initialized'); } @@ -61,6 +61,6 @@ protected function errorBag(): ErrorBag public function __clone() { - $this->errorBag->reset(); + $this->errorBag()->reset(); } } From 551a19033185f6eaaba4430bcfa7e1058ea8f10f Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 27 Jun 2025 21:26:53 +0200 Subject: [PATCH 12/21] feat: more support for draft-06 --- .../Drafts/Draft06/Draft06Constraint.php | 1 + .../Constraints/Drafts/Draft06/Factory.php | 1 + .../Drafts/Draft06/PatternConstraint.php | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index fc83894d..b153f112 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -56,6 +56,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('const', $value, $schema, $path, $i); $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); $this->checkForKeyword('format', $value, $schema, $path, $i); + $this->checkForKeyword('pattern', $value, $schema, $path, $i); } /** diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index ae821d90..1bf25ade 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -38,6 +38,7 @@ class Factory extends \JsonSchema\Constraints\Factory 'contains' => ContainsConstraint::class, 'propertyNames' => PropertiesNamesConstraint::class, 'patternProperties' => PatternPropertiesConstraint::class, + 'pattern' => PatternConstraint::class, 'properties' => PropertiesConstraint::class, 'items' => ItemsConstraint::class, ]; diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php new file mode 100644 index 00000000..fd241842 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php @@ -0,0 +1,41 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'pattern')) { + return; + } + + if (!is_string($value)) { + return; + } + + if (preg_match('/' . str_replace('/', '\/', $schema->pattern) . '/', $value) === 1) { + return; + } + + $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); + } +} From 605fbecf4f54f6a32723b500da5e0eb5716d015d Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Mon, 30 Jun 2025 19:48:27 +0200 Subject: [PATCH 13/21] fix: resolve dependencies keyword for oject with subschema --- .../Drafts/Draft06/DependenciesConstraint.php | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php index 3f0ecded..5b8e86b4 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php @@ -6,7 +6,6 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; -use JsonSchema\Constraints\Factory; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -14,9 +13,13 @@ class DependenciesConstraint implements ConstraintInterface { use ErrorBagProxy; + /** @var Factory */ + private $factory; + public function __construct(?Factory $factory = null) { - $this->initialiseErrorBag($factory ?: new Factory()); + $this->factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void @@ -40,9 +43,20 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::FALSE(), $path, ['dependant' => $dependant]); continue; } - foreach ($dependencies as $dependency) { - if (property_exists($value, $dependant) && !property_exists($value, $dependency)) { - $this->addError(ConstraintError::DEPENDENCIES(), $path, ['dependant' => $dependant, 'dependency' => $dependency]); + + if (is_array($dependencies)) { + foreach ($dependencies as $dependency) { + if (property_exists($value, $dependant) && !property_exists($value, $dependency)) { + $this->addError(ConstraintError::DEPENDENCIES(), $path, ['dependant' => $dependant, 'dependency' => $dependency]); + } + } + } + + if (is_object($dependencies)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $dependencies, $path, $i); + if (!$schemaConstraint->isValid()) { + $this->addErrors($schemaConstraint->getErrors()); } } } From 80638e7d7af4f4a4fc6635378835626e52927a94 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Mon, 30 Jun 2025 23:13:41 +0200 Subject: [PATCH 14/21] feat: add support for $ref --- .../Constraints/Drafts/Draft06/Draft06Constraint.php | 12 ++++++++++-- .../Constraints/Drafts/Draft06/Factory.php | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index b153f112..eb2d501f 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -7,12 +7,19 @@ use JsonSchema\ConstraintError; use JsonSchema\Constraints\Constraint; use JsonSchema\Entity\JsonPointer; +use JsonSchema\SchemaStorage; +use JsonSchema\Uri\UriRetriever; class Draft06Constraint extends Constraint { - public function __construct() + public function __construct(\JsonSchema\Constraints\Factory $factory = null) { - parent::__construct(new Factory()); + + parent::__construct(new Factory( + $factory ? $factory->getSchemaStorage() : new SchemaStorage(), + $factory ? $factory->getUriRetriever() : new UriRetriever(), + $factory ? $factory->getConfig() : Constraint::CHECK_MODE_NORMAL, + )); } public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void @@ -26,6 +33,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } // Apply defaults + $this->checkForKeyword('ref', $value, $schema, $path, $i); $this->checkForKeyword('required', $value, $schema, $path, $i); $this->checkForKeyword('contains', $value, $schema, $path, $i); $this->checkForKeyword('properties', $value, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php index 1bf25ade..1d23f9ff 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -41,5 +41,6 @@ class Factory extends \JsonSchema\Constraints\Factory 'pattern' => PatternConstraint::class, 'properties' => PropertiesConstraint::class, 'items' => ItemsConstraint::class, + 'ref' => RefConstraint::class, ]; } From 2f6cecc950d015c9d27f3d67b24603c778aa54ad Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Mon, 30 Jun 2025 23:21:25 +0200 Subject: [PATCH 15/21] fix: Fix multiple off implementation --- .../Drafts/Draft06/Draft06Constraint.php | 4 +- .../Drafts/Draft06/MultipleOfConstraint.php | 15 +++--- .../Drafts/Draft06/RefConstraint.php | 46 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php index eb2d501f..b66d76a8 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -12,13 +12,13 @@ class Draft06Constraint extends Constraint { - public function __construct(\JsonSchema\Constraints\Factory $factory = null) + public function __construct(?\JsonSchema\Constraints\Factory $factory = null) { parent::__construct(new Factory( $factory ? $factory->getSchemaStorage() : new SchemaStorage(), $factory ? $factory->getUriRetriever() : new UriRetriever(), - $factory ? $factory->getConfig() : Constraint::CHECK_MODE_NORMAL, + $factory ? $factory->getConfig() : Constraint::CHECK_MODE_NORMAL )); } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php index e77ef01c..8cf7d7ef 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -29,21 +29,22 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } - if (fmod($value, $schema->multipleOf) === 0.0 || $this->isMultipleOf((string) $value, (string) $schema->multipleOf)) { + if ($this->isMultipleOf($value, $schema->multipleOf)) { return; } $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); } - private function isMultipleOf(string $value, string $multipleOf): bool + private function isMultipleOf($number1, $number2) { - if (bccomp($multipleOf, '0', 20) === 0) { - return false; - } + $modulus = ($number1 - round($number1 / $number2) * $number2); + $precision = 0.0000000001; - $div = bcdiv($value, $multipleOf, 20); + if (-$precision < $modulus && $modulus < $precision) { + return true; + } - return bccomp(bcmod($div, '1', 20), '0', 20) === 0; + return false; } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php new file mode 100644 index 00000000..681b550b --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php @@ -0,0 +1,46 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, '$ref')) { + return; + } + + try { + $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); + } catch (\Exception $e) { + return; + } + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $refSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + } +} From 18613710218ada953b2c8f2813900e47f132d5da Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Tue, 1 Jul 2025 19:46:32 +0200 Subject: [PATCH 16/21] fix: include $id in schema resolving as draft-06 and upwards use $id as keyword --- src/JsonSchema/SchemaStorage.php | 22 ++++++++++++++++++---- src/JsonSchema/Validator.php | 3 +++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/JsonSchema/SchemaStorage.php b/src/JsonSchema/SchemaStorage.php index e7e48ef0..8c069947 100644 --- a/src/JsonSchema/SchemaStorage.php +++ b/src/JsonSchema/SchemaStorage.php @@ -106,9 +106,10 @@ private function expandRefs(&$schema, ?string $parentId = null): void continue; } + $schemaId = $this->findSchemaIdInObject($schema); $childId = $parentId; - if (property_exists($schema, 'id') && is_string($schema->id) && $childId !== $schema->id) { - $childId = $this->uriResolver->resolve($schema->id, $childId); + if (is_string($schemaId) && $childId !== $schemaId) { + $childId = $this->uriResolver->resolve($schemaId, $childId); } $this->expandRefs($member, $childId); @@ -196,17 +197,30 @@ private function scanForSubschemas($schema, string $parentId): void continue; } - if (property_exists($potentialSubSchema, 'id') && is_string($potentialSubSchema->id) && property_exists($potentialSubSchema, 'type')) { + $potentialSubSchemaId = $this->findSchemaIdInObject($potentialSubSchema); + if (is_string($potentialSubSchemaId) && property_exists($potentialSubSchema, 'type')) { // Enum and const don't allow id as a keyword, see https://github.com/json-schema-org/JSON-Schema-Test-Suite/pull/471 if (in_array($propertyName, ['enum', 'const'])) { continue; } // Found sub schema - $this->addSchema($this->uriResolver->resolve($potentialSubSchema->id, $parentId), $potentialSubSchema); + $this->addSchema($this->uriResolver->resolve($potentialSubSchemaId, $parentId), $potentialSubSchema); } $this->scanForSubschemas($potentialSubSchema, $parentId); } } + + private function findSchemaIdInObject(object $schema): ?string + { + if (property_exists($schema, 'id') && is_string($schema->id)) { + return $schema->id; + } + if (property_exists($schema, '$id') && is_string($schema->{'$id'})) { + return $schema->{'$id'}; + } + + return null; + } } diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index d3083b2d..9cb16dff 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -61,6 +61,9 @@ public function validate(&$value, $schema = null, ?int $checkMode = null): int if (LooseTypeCheck::propertyExists($schema, 'id')) { $schemaURI = LooseTypeCheck::propertyGet($schema, 'id'); } + if (LooseTypeCheck::propertyExists($schema, '$id')) { + $schemaURI = LooseTypeCheck::propertyGet($schema, '$id'); + } $this->factory->getSchemaStorage()->addSchema($schemaURI, $schema); $validator = $this->factory->createInstanceFor('schema'); From 80337e3bb25b281bc1b9ded39e54c421bd2773c6 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Tue, 1 Jul 2025 20:08:15 +0200 Subject: [PATCH 17/21] fix: fixes for unique items, pattern properties and additional properties --- .../Drafts/Draft06/AdditionalPropertiesConstraint.php | 2 +- .../Constraints/Drafts/Draft06/PatternPropertiesConstraint.php | 2 +- .../Constraints/Drafts/Draft06/UniqueItemsConstraint.php | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index c2fa41e6..4355a89e 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -47,7 +47,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($additionalProperties as $key => $_) { foreach ($patterns as $pattern) { - if (preg_match("/{$pattern}/", $key)) { + if (preg_match("/{$pattern}/", (string) $key)) { unset($additionalProperties[$key]); break; } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php index 3c74abb2..58863b8f 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -35,7 +35,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n foreach ($properties as $propertyName => $propertyValue) { foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { - if (preg_match('/' . str_replace('/', '\/', $patternPropertyRegex) . '/', $propertyName)) { + if (preg_match('/' . str_replace('/', '\/', $patternPropertyRegex) . '/', (string) $propertyName)) { $schemaConstraint = $this->factory->createInstanceFor('schema'); $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); if ($schemaConstraint->isValid()) { diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php index 1f1c4b9e..93453401 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php @@ -25,6 +25,9 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if (!property_exists($schema, 'uniqueItems')) { return; } + if (!is_array($value)) { + return; + } if ($schema->uniqueItems !== true) { // If unique items not is true duplicates are allowed. From edcdde208a7bc669a35e1d8f97c8c1ce2f20043f Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Tue, 1 Jul 2025 20:14:44 +0200 Subject: [PATCH 18/21] fix: fix for minimum keyword --- .../Constraints/Drafts/Draft06/MinimumConstraint.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php index aca6c1d3..e5f61b82 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php @@ -25,6 +25,10 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n return; } + if (! is_numeric($value)) { + return; + } + if ($value >= $schema->minimum) { return; } From 7b9ea0ea3b278e560c6f897b7ba35d2001e18080 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 4 Jul 2025 15:46:37 +0200 Subject: [PATCH 19/21] feat: Improving on format keyword --- src/JsonSchema/ConstraintError.php | 2 + .../Drafts/Draft06/FormatConstraint.php | 40 +++++++++++++++++- src/JsonSchema/Rfc3339.php | 42 +++++++++++++++---- .../Tool/Validator/UriValidator.php | 20 +++++++-- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index d781fa1f..f2fdd3f9 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -28,6 +28,7 @@ class ConstraintError extends Enum public const FORMAT_EMAIL = 'emailFormat'; public const FORMAT_HOSTNAME = 'styleHostName'; public const FORMAT_IP = 'ipFormat'; + public const FORMAT_JSON_POINTER = 'jsonPointerFormat'; public const FORMAT_PHONE = 'phoneFormat'; public const FORMAT_REGEX= 'regexFormat'; public const FORMAT_STYLE = 'styleFormat'; @@ -84,6 +85,7 @@ public function getMessage() self::FORMAT_EMAIL => 'Invalid email', self::FORMAT_HOSTNAME => 'Invalid hostname', self::FORMAT_IP => 'Invalid IP address', + self::FORMAT_JSON_POINTER => 'Invalid JSON pointer', self::FORMAT_PHONE => 'Invalid phone number', self::FORMAT_REGEX=> 'Invalid regex format %s', self::FORMAT_STYLE => 'Invalid style', diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index 392fbe80..dbfe2a3f 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -44,7 +44,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } break; case 'date-time': - if (Rfc3339::createFromString($value) === null) { + if (!$this->validateRfc3339DateTime($value)) { $this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, ['dateTime' => $value, 'format' => $schema->format]); } break; @@ -108,6 +108,12 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, ['format' => $schema->format]); } break; + case 'json-pointer': + if (!$this->validateJsonPointer($value)) { + $this->addError(ConstraintError::FORMAT_JSON_POINTER(), $path, ['format' => $schema->format]); + } + break; + break; default: break; } @@ -167,4 +173,36 @@ private function validateHostname(string $host): bool return preg_match($hostnameRegex, $host) !== false; } + + private function validateJsonPointer(string $value): bool + { + // Must be empty or start with a forward slash + if ($value !== '' && $value[0] !== '/') { + return false; + } + + // Split into reference tokens and check for invalid escape sequences + $tokens = explode('/', $value); + array_shift($tokens); // remove leading empty part due to leading slash + + foreach ($tokens as $token) { + // "~" must only be followed by "0" or "1" + if (preg_match('/~(?![01])/', $token)) { + return false; + } + } + + return true; + } + + private function validateRfc3339DateTime(string $value): bool + { + $dateTime = Rfc3339::createFromString($value); + if (is_null($dateTime)) { + return false; + } + + // Compare value and date result to be equal + return true; + } } diff --git a/src/JsonSchema/Rfc3339.php b/src/JsonSchema/Rfc3339.php index 3524f681..297b4d61 100644 --- a/src/JsonSchema/Rfc3339.php +++ b/src/JsonSchema/Rfc3339.php @@ -6,27 +6,51 @@ class Rfc3339 { - private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ]{1}\d{2}:\d{2}:\d{2})(\.\d+)?(Z|([+-]\d{2}):?(\d{2}))$/'; + private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ](0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):((?:[0-5][0-9]|60)))(\.\d+)?(Z|([+-](0[0-9]|1[0-9]|2[0-3])):([0-5][0-9]))$/'; /** * Try creating a DateTime instance * - * @param string $string + * @param string $input * * @return \DateTime|null */ - public static function createFromString($string) + public static function createFromString($input): ?\DateTime { - if (!preg_match(self::REGEX, strtoupper($string), $matches)) { + if (!preg_match(self::REGEX, strtoupper($input), $matches)) { return null; } + $input = strtoupper($input); // Cleanup for lowercase t and z + $inputHasTSeparator = strpos($input, 'T'); + $dateAndTime = $matches[1]; - $microseconds = $matches[2] ?: '.000000'; - $timeZone = 'Z' !== $matches[3] ? $matches[4] . ':' . $matches[5] : '+00:00'; - $dateFormat = strpos($dateAndTime, 'T') === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP'; - $dateTime = \DateTime::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC')); + $microseconds = $matches[5] ?: '.000000'; + $timeZone = 'Z' !== $matches[6] ? $matches[6] : '+00:00'; + $dateFormat = $inputHasTSeparator === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP'; + $dateTime = \DateTimeImmutable::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC')); + + if ($dateTime === false) { + return null; + } + + $utcDateTime = $dateTime->setTimezone(new \DateTimeZone('+00:00')); + $oneSecond = new \DateInterval('PT1S'); + + // handle leap seconds + if ($matches[4] === '60' && $utcDateTime->sub($oneSecond)->format('H:i:s') === '23:59:59') { + $dateTime = $dateTime->sub($oneSecond); + $matches[1] = str_replace(':60', ':59', $matches[1]); + } + + // Ensure we still have the same year, month, day, hour, minutes and seconds to ensure no rollover took place. + if ($dateTime->format($inputHasTSeparator ? 'Y-m-d\TH:i:s' : 'Y-m-d H:i:s') !== $matches[1]) { + return null; + } + + $mutable = \DateTime::createFromFormat('U.u', $dateTime->format('U.u')); + $mutable->setTimezone($dateTime->getTimezone()); - return $dateTime ?: null; + return $mutable; } } diff --git a/src/JsonSchema/Tool/Validator/UriValidator.php b/src/JsonSchema/Tool/Validator/UriValidator.php index d7ed0a83..b761f91f 100644 --- a/src/JsonSchema/Tool/Validator/UriValidator.php +++ b/src/JsonSchema/Tool/Validator/UriValidator.php @@ -19,12 +19,16 @@ public static function isValid(string $uri): bool (\#(.*))? # Optional fragment $/ix'; - // RFC 3986: Non-Hierarchical URIs (mailto, data, urn) + // RFC 3986: Non-Hierarchical URIs (mailto, data, urn, news) $nonHierarchicalPattern = '/^ - (mailto|data|urn): # Only allow known non-hierarchical schemes - (.+) # Must contain at least one character after scheme + (mailto|data|urn|news|tel): # Only allow known non-hierarchical schemes + (.+) # Must contain at least one character after scheme $/ix'; + // Validation for newsgroup name (alphanumeric + dots, no empty segments) + $newsGroupPattern = '/^[a-z0-9]+(\.[a-z0-9]+)*$/i'; + $telPattern = '/^\+?[0-9.\-() ]+$/'; // Allows +, digits, separators + // RFC 5322-compliant email validation for `mailto:` URIs $emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; @@ -40,7 +44,7 @@ public static function isValid(string $uri): bool return false; } - // Validate path (reject illegal characters: < > { } | \ ^ `) + // Validate the path (reject illegal characters: < > { } | \ ^ `) if (!empty($matches[6]) && preg_match('/[<>{}|\\\^`]/', $matches[6])) { return false; } @@ -57,6 +61,14 @@ public static function isValid(string $uri): bool return preg_match($emailPattern, $matches[2]) === 1; } + if ($scheme === 'news') { + return preg_match($newsGroupPattern, $matches[2]) === 1; + } + + if ($scheme === 'tel') { + return preg_match($telPattern, $matches[2]) === 1; + } + return true; // Valid non-hierarchical URI } From 286da5b1c680e4f9d00c51fe0e0d0b2e4166f4ec Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 4 Jul 2025 16:45:17 +0200 Subject: [PATCH 20/21] fix: Resolve failling test cases for format hostname --- .../Constraints/Drafts/Draft06/FormatConstraint.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index dbfe2a3f..a3a88f0f 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -169,9 +169,9 @@ private function validatePhone(string $phone): bool private function validateHostname(string $host): bool { - $hostnameRegex = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/i'; + $hostnameRegex = '/^(?!-)(?!.*?[^A-Za-z0-9\-\.])(?:(?!-)[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)*(?!-)[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$/'; - return preg_match($hostnameRegex, $host) !== false; + return preg_match($hostnameRegex, $host) === 1; } private function validateJsonPointer(string $value): bool From 4f24aca9de9cb1ffda0d25ffb6a64d2bec012fb8 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 4 Jul 2025 17:00:23 +0200 Subject: [PATCH 21/21] fix: add handling of uri-template format; fail on backslash in uri reference --- src/JsonSchema/ConstraintError.php | 2 ++ .../Drafts/Draft06/FormatConstraint.php | 14 +++++++++++++- .../Tool/Validator/RelativeReferenceValidator.php | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index f2fdd3f9..ed6cf03e 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -33,6 +33,7 @@ class ConstraintError extends Enum public const FORMAT_REGEX= 'regexFormat'; public const FORMAT_STYLE = 'styleFormat'; public const FORMAT_TIME = 'timeFormat'; + public const FORMAT_URI_TEMPLATE = 'uriTemplateFormat'; public const FORMAT_URL = 'urlFormat'; public const FORMAT_URL_REF = 'urlRefFormat'; public const INVALID_SCHEMA = 'invalidSchema'; @@ -90,6 +91,7 @@ public function getMessage() self::FORMAT_REGEX=> 'Invalid regex format %s', self::FORMAT_STYLE => 'Invalid style', self::FORMAT_TIME => 'Invalid time %s, expected format hh:mm:ss', + self::FORMAT_URI_TEMPLATE => 'Invalid URI template format', self::FORMAT_URL => 'Invalid URL format', self::FORMAT_URL_REF => 'Invalid URL reference format', self::LENGTH_MAX => 'Must be at most %d characters long', diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index a3a88f0f..578a27c3 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -96,6 +96,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); } break; + case 'uri-template': + if (!$this->validateUriTemplate($value)) { + $this->addError(ConstraintError::FORMAT_URI_TEMPLATE(), $path, ['format' => $schema->format]); + } + break; case 'email': if (filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE | FILTER_FLAG_EMAIL_UNICODE) === null) { @@ -113,7 +118,6 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->addError(ConstraintError::FORMAT_JSON_POINTER(), $path, ['format' => $schema->format]); } break; - break; default: break; } @@ -205,4 +209,12 @@ private function validateRfc3339DateTime(string $value): bool // Compare value and date result to be equal return true; } + + private function validateUriTemplate(string $value): bool + { + return preg_match( + '/^(?:[^\{\}]*|\{[a-zA-Z0-9_:%\/\.~\-\+\*]+\})*$/', + $value + ) === 1; + } } diff --git a/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php b/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php index 2409f144..fd95f7bf 100644 --- a/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php +++ b/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php @@ -16,6 +16,10 @@ public static function isValid(string $ref): bool } // Additional checks for invalid cases + if (strpos($ref, '\\') !== false) { + return false; // Backslashes are not allowed in URI references + } + if (preg_match('/^(http|https):\/\//', $ref)) { return false; // Absolute URI }