From ea97d0d689c21da5be1e9644d8b34ce303e9f1ef Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 13:48:34 +0200 Subject: [PATCH 01/10] test: Enable draft-7 in tests --- tests/JsonSchemaTestSuiteTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 9e07c3ad..df6bfbc1 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -64,7 +64,7 @@ public function casesDataProvider(): \Generator $drafts = array_filter(glob($testDir . '/*'), static function (string $filename) { return is_dir($filename); }); - $skippedDrafts = ['draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; + $skippedDrafts = ['draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { if (in_array(basename($draft), $skippedDrafts, true)) { From bdd32e8afd36d81d77ec36bb9fc96806fcf39808 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 13:51:37 +0200 Subject: [PATCH 02/10] fix: Fix missing variable --- tests/JsonSchemaTestSuiteTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index df6bfbc1..c7a747e8 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -67,7 +67,8 @@ public function casesDataProvider(): \Generator $skippedDrafts = ['draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { - if (in_array(basename($draft), $skippedDrafts, true)) { + $baseDraftName = basename($draft); + if (in_array($baseDraftName, $skippedDrafts, true)) { continue; } From 5a36dd08cb4a3d0db852d041239f0e92b00957eb Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 13:51:56 +0200 Subject: [PATCH 03/10] test: Force strict mode for draft 7 --- tests/JsonSchemaTestSuiteTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index c7a747e8..6688b80f 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -190,6 +190,7 @@ private function getCheckModeForDraft(string $draft): int { switch ($draft) { case 'draft6': + case 'draft7': return Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT; default: return Constraint::CHECK_MODE_NORMAL; From 6f614297d820f1200264c1d12489d18850dbda30 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 14:22:54 +0200 Subject: [PATCH 04/10] fix: Use local copy for Draft 7 schema spec --- src/JsonSchema/Uri/UriRetriever.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index 361512a9..3043ca35 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -31,7 +31,7 @@ class UriRetriever implements BaseUriRetrieverInterface */ protected $translationMap = [ // use local copies of the spec schemas - '|^https?://json-schema.org/draft-(0[346])/schema#?|' => 'package://dist/schema/json-schema-draft-$1.json' + '|^https?://json-schema.org/draft-(0[3467])/schema#?|' => 'package://dist/schema/json-schema-draft-$1.json' ]; /** From ebaa7d0dc722dbf5f2c6d6b00dc44a1a7d922860 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 14:23:22 +0200 Subject: [PATCH 05/10] test: Pass draft identifier to testcase --- src/JsonSchema/DraftIdentifiers.php | 45 +++++++++++++++++++---------- tests/JsonSchemaTestSuiteTest.php | 9 ++++-- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/JsonSchema/DraftIdentifiers.php b/src/JsonSchema/DraftIdentifiers.php index d0e05e78..c8f82718 100644 --- a/src/JsonSchema/DraftIdentifiers.php +++ b/src/JsonSchema/DraftIdentifiers.php @@ -21,24 +21,39 @@ class DraftIdentifiers extends Enum public const DRAFT_2019_09 = 'https://json-schema.org/draft/2019-09/schema'; public const DRAFT_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; + /** @var array */ + private const MAPPING = [ + self::DRAFT_3 => 'draft03', + self::DRAFT_4 => 'draft04', + self::DRAFT_6 => 'draft06', + self::DRAFT_7 => 'draft07', + self::DRAFT_2019_09 => 'draft2019-09', + self::DRAFT_2020_12 => 'draft2020-12', + ]; + + private const FALLBACK_MAPPING = [ + 'draft3' => self::DRAFT_3, + 'draft4' => self::DRAFT_4, + 'draft6' => self::DRAFT_6, + 'draft7' => self::DRAFT_7, + ]; + public function toConstraintName(): string { - switch ($this->getValue()) { - case self::DRAFT_3: - return 'draft03'; - case self::DRAFT_4: - return 'draft04'; - case self::DRAFT_6: - return 'draft06'; - case self::DRAFT_7: - return 'draft07'; - case self::DRAFT_2019_09: - return 'draft2019-09'; - case self::DRAFT_2020_12: - return 'draft2020-12'; - default: - throw new \Exception('Unsupported schema URI: ' . $this->getValue()); + return self::MAPPING[$this->getValue()]; + } + + public static function fromConstraintName(string $name): DraftIdentifiers + { + $reverseMap = array_flip(self::MAPPING); + if (!array_key_exists($name, $reverseMap)) { + if (array_key_exists($name, self::FALLBACK_MAPPING)) { + return DraftIdentifiers::byValue(self::FALLBACK_MAPPING[$name]); + } + throw new \InvalidArgumentException("$name is not a valid constraint name."); } + + return DraftIdentifiers::byValue($reverseMap[$name]); } public function withoutFragment(): string diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 6688b80f..46b5c44e 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -7,6 +7,7 @@ use CallbackFilterIterator; use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; +use JsonSchema\DraftIdentifiers; use JsonSchema\SchemaStorage; use JsonSchema\SchemaStorageInterface; use JsonSchema\Validator; @@ -28,6 +29,7 @@ public function testTestCaseValidatesCorrectly( $schema, $data, int $checkMode, + DraftIdentifiers $draft, bool $expectedValidationResult, bool $optional ): void { @@ -35,7 +37,9 @@ public function testTestCaseValidatesCorrectly( $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)); + $factory = new Factory($schemaStorage); + $factory->setDefaultDialect($draft->getValue()); + $validator = new Validator($factory); try { $validator->validate($data, $schema, $checkMode); @@ -64,7 +68,7 @@ public function casesDataProvider(): \Generator $drafts = array_filter(glob($testDir . '/*'), static function (string $filename) { return is_dir($filename); }); - $skippedDrafts = ['draft2019-09', 'draft2020-12', 'draft-next', 'latest']; + $skippedDrafts = ['draft3', 'draft4', 'draft6', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { $baseDraftName = basename($draft); @@ -105,6 +109,7 @@ function ($file) { 'schema' => $testCase->schema, 'data' => $test->data, 'checkMode' => $this->getCheckModeForDraft($baseDraftName), + 'draft' => DraftIdentifiers::fromConstraintName($baseDraftName), 'expectedValidationResult' => $test->valid, 'optional' => str_contains($file->getPathname(), '/optional/') ]; From c903df00ba06f8535f955de97e3e1a2adf5868f7 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 14:23:55 +0200 Subject: [PATCH 06/10] feat: Copy draft07 from draft06 to get started --- .../Draft07/AdditionalItemsConstraint.php | 61 +++++ .../AdditionalPropertiesConstraint.php | 93 ++++++++ .../Drafts/Draft07/AllOfConstraint.php | 42 ++++ .../Drafts/Draft07/AnyOfConstraint.php | 51 ++++ .../Drafts/Draft07/ConstConstraint.php | 35 +++ .../Drafts/Draft07/ContainsConstraint.php | 47 ++++ .../Drafts/Draft07/DependenciesConstraint.php | 64 +++++ .../Drafts/Draft07/Draft07Constraint.php | 81 +++++++ .../Drafts/Draft07/EnumConstraint.php | 41 ++++ .../Draft07/ExclusiveMaximumConstraint.php | 38 +++ .../Draft07/ExclusiveMinimumConstraint.php | 38 +++ .../Constraints/Drafts/Draft07/Factory.php | 46 ++++ .../Drafts/Draft07/FormatConstraint.php | 220 ++++++++++++++++++ .../Drafts/Draft07/ItemsConstraint.php | 52 +++++ .../Drafts/Draft07/MaxItemsConstraint.php | 39 ++++ .../Drafts/Draft07/MaxLengthConstraint.php | 39 ++++ .../Draft07/MaxPropertiesConstraint.php | 39 ++++ .../Drafts/Draft07/MaximumConstraint.php | 38 +++ .../Drafts/Draft07/MinItemsConstraint.php | 39 ++++ .../Drafts/Draft07/MinLengthConstraint.php | 39 ++++ .../Draft07/MinPropertiesConstraint.php | 39 ++++ .../Drafts/Draft07/MinimumConstraint.php | 38 +++ .../Drafts/Draft07/MultipleOfConstraint.php | 54 +++++ .../Drafts/Draft07/NotConstraint.php | 40 ++++ .../Drafts/Draft07/OneOfConstraint.php | 50 ++++ .../Drafts/Draft07/PatternConstraint.php | 63 +++++ .../Draft07/PatternPropertiesConstraint.php | 72 ++++++ .../Drafts/Draft07/PropertiesConstraint.php | 48 ++++ .../Draft07/PropertiesNamesConstraint.php | 65 ++++++ .../Drafts/Draft07/RefConstraint.php | 45 ++++ .../Drafts/Draft07/RequiredConstraint.php | 58 +++++ .../Drafts/Draft07/TypeConstraint.php | 50 ++++ .../Drafts/Draft07/UniqueItemsConstraint.php | 48 ++++ src/JsonSchema/Constraints/Factory.php | 1 + 34 files changed, 1813 insertions(+) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/AllOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/AnyOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/ConstConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/ContainsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/DependenciesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/EnumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMaximumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMinimumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/Factory.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MaxItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MaxLengthConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MaxPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MaximumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MinItemsConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MinLengthConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MinPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MinimumConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/MultipleOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/NotConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/OneOfConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/RequiredConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/TypeConstraint.php create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/UniqueItemsConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php new file mode 100644 index 00000000..8444d053 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalItemsConstraint.php @@ -0,0 +1,61 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->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 ($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); + + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php new file mode 100644 index 00000000..27d5c4f6 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php @@ -0,0 +1,93 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->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 = 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($this->createPregMatchPattern($pattern), (string) $key)) { + unset($additionalProperties[$key]); + break; + } + } + } + } + + 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]); + } + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ +// '\D' => '[^0-9]', +// '\d' => '[0-9]', + '\p{digit}' => '\p{Nd}', +// '\w' => '[A-Za-z0-9_]', +// '\W' => '[^A-Za-z0-9_]', +// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space, + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/AllOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/AllOfConstraint.php new file mode 100644 index 00000000..6aa9fdf2 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/AllOfConstraint.php @@ -0,0 +1,42 @@ +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/Draft07/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/AnyOfConstraint.php new file mode 100644 index 00000000..85242962 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/AnyOfConstraint.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, 'anyOf')) { + return; + } + + foreach ($schema->anyOf as $anyOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + try { + $schemaConstraint->check($value, $anyOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + $this->errorBag()->reset(); + + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + } catch (ValidationException $e) { + } + } + + $this->addError(ConstraintError::ANY_OF(), $path); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/ConstConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/ConstConstraint.php new file mode 100644 index 00000000..e0ff337b --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/ContainsConstraint.php new file mode 100644 index 00000000..e2e48ffe --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/ContainsConstraint.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, 'contains')) { + return; + } + + $properties = []; + if (!is_array($value)) { + return; + } + + foreach ($value 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/Draft07/DependenciesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/DependenciesConstraint.php new file mode 100644 index 00000000..f4b03ff8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/DependenciesConstraint.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, 'dependencies')) { + return; + } + + if (!is_object($value)) { + return; + } + + 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; + } + + 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()); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php new file mode 100644 index 00000000..925f5ba1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php @@ -0,0 +1,81 @@ +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 + { + if (is_bool($schema)) { + if ($schema === false) { + $this->addError(ConstraintError::FALSE(), $path, []); + } + + return; + } + + // 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); +// $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); +// $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); +// $this->checkForKeyword('type', $value, $schema, $path, $i); +// $this->checkForKeyword('not', $value, $schema, $path, $i); +// $this->checkForKeyword('dependencies', $value, $schema, $path, $i); +// $this->checkForKeyword('allOf', $value, $schema, $path, $i); +// $this->checkForKeyword('anyOf', $value, $schema, $path, $i); +// $this->checkForKeyword('oneOf', $value, $schema, $path, $i); +// +// $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); +// $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); +// $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); +// $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); +// $this->checkForKeyword('format', $value, $schema, $path, $i); +// $this->checkForKeyword('pattern', $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); + $validator->check($value, $schema, $path, $i); + + $this->addErrors($validator->getErrors()); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/EnumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/EnumConstraint.php new file mode 100644 index 00000000..6a81401d --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/EnumConstraint.php @@ -0,0 +1,41 @@ +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/Draft07/ExclusiveMaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMaximumConstraint.php new file mode 100644 index 00000000..484768f8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMaximumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'exclusiveMaximum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value < $schema->exclusiveMaximum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, ['exclusiveMaximum' => $schema->exclusiveMaximum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMinimumConstraint.php new file mode 100644 index 00000000..c97c8c53 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/ExclusiveMinimumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'exclusiveMinimum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value > $schema->exclusiveMinimum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, ['exclusiveMinimum' => $schema->exclusiveMinimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php new file mode 100644 index 00000000..913b8ee8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php @@ -0,0 +1,46 @@ + + */ + protected $constraintMap = [ + 'schema' => Draft07Constraint::class, + 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'additionalItems' => AdditionalItemsConstraint::class, + 'dependencies' => DependenciesConstraint::class, + 'type' => TypeConstraint::class, + 'const' => ConstConstraint::class, + 'enum' => EnumConstraint::class, + 'uniqueItems' => UniqueItemsConstraint::class, + 'minItems' => MinItemsConstraint::class, + '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, + '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, + 'pattern' => PatternConstraint::class, + 'properties' => PropertiesConstraint::class, + 'items' => ItemsConstraint::class, + 'ref' => RefConstraint::class, + ]; +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php new file mode 100644 index 00000000..b144a0a1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php @@ -0,0 +1,220 @@ +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 (!$this->validateRfc3339DateTime($value)) { + $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 '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) { + $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; + case 'json-pointer': + if (!$this->validateJsonPointer($value)) { + $this->addError(ConstraintError::FORMAT_JSON_POINTER(), $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(string $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\-]{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) === 1; + } + + 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; + } + + private function validateUriTemplate(string $value): bool + { + return preg_match( + '/^(?:[^\{\}]*|\{[a-zA-Z0-9_:%\/\.~\-\+\*]+\})*$/', + $value + ) === 1; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.php new file mode 100644 index 00000000..16abae32 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/ItemsConstraint.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, 'items')) { + return; + } + + if (!is_array($value)) { + return; + } + + foreach ($value as $propertyName => $propertyValue) { + $itemSchema = $schema->items; + if (is_array($itemSchema)) { + if (!array_key_exists($propertyName, $itemSchema)) { + continue; + } + + $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/Draft07/MaxItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MaxItemsConstraint.php new file mode 100644 index 00000000..101a0cf9 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MaxLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MaxLengthConstraint.php new file mode 100644 index 00000000..9043cf92 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MaxPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MaxPropertiesConstraint.php new file mode 100644 index 00000000..106e98b0 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MaximumConstraint.php new file mode 100644 index 00000000..dd9e1b98 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/MaximumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maximum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value <= $schema->maximum) { + return; + } + + $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/MinItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MinItemsConstraint.php new file mode 100644 index 00000000..02617039 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MinLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MinLengthConstraint.php new file mode 100644 index 00000000..5c7243a8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MinPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MinPropertiesConstraint.php new file mode 100644 index 00000000..07014fb6 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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/Draft07/MinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MinimumConstraint.php new file mode 100644 index 00000000..5f1b480c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/MinimumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minimum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value >= $schema->minimum) { + return; + } + + $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/MultipleOfConstraint.php new file mode 100644 index 00000000..134cfda2 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/MultipleOfConstraint.php @@ -0,0 +1,54 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'multipleOf')) { + return; + } + + if (!is_int($schema->multipleOf) && !is_float($schema->multipleOf) && $schema->multipleOf <= 0.0) { + return; + } + + if (!is_int($value) && !is_float($value)) { + return; + } + + if ($this->isMultipleOf($value, $schema->multipleOf)) { + return; + } + + $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); + } + + /** + * @param int|float $number1 + * @param int|float $number2 + */ + private function isMultipleOf($number1, $number2): bool + { + $modulus = ($number1 - round($number1 / $number2) * $number2); + $precision = 0.0000000001; + + return -$precision < $modulus && $modulus < $precision; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/NotConstraint.php new file mode 100644 index 00000000..fdba81bf --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/NotConstraint.php @@ -0,0 +1,40 @@ +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/Draft07/OneOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/OneOfConstraint.php new file mode 100644 index 00000000..4523f73c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/OneOfConstraint.php @@ -0,0 +1,50 @@ +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/Draft07/PatternConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php new file mode 100644 index 00000000..062b61d3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/PatternConstraint.php @@ -0,0 +1,63 @@ +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; + } + + $matchPattern = $this->createPregMatchPattern($schema->pattern); + if (preg_match($matchPattern, $value) === 1) { + return; + } + + $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ + '\D' => '[^0-9]', + '\d' => '[0-9]', + '\p{digit}' => '[0-9]', + '\w' => '[A-Za-z0-9_]', + '\W' => '[^A-Za-z0-9_]', + '\s' => '[\s\x{200B}]', // Explicitly include zero width white space + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php new file mode 100644 index 00000000..d821abf1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/PatternPropertiesConstraint.php @@ -0,0 +1,72 @@ +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; + } + + if (!is_object($value)) { + return; + } + + $properties = get_object_vars($value); + + foreach ($properties as $propertyName => $propertyValue) { + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { + $matchPattern = $this->createPregMatchPattern($patternPropertyRegex); + if (preg_match($matchPattern, (string) $propertyName)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ +// '\D' => '[^0-9]', + '\d' => '[0-9]', + '\p{digit}' => '[0-9]', +// '\w' => '[A-Za-z0-9_]', +// '\W' => '[^A-Za-z0-9_]', +// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.php new file mode 100644 index 00000000..25c55e66 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesConstraint.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, '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/Draft07/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php new file mode 100644 index 00000000..7bf9ea74 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php @@ -0,0 +1,65 @@ +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/Constraints/Drafts/Draft07/RefConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php new file mode 100644 index 00000000..9ec6efda --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/RefConstraint.php @@ -0,0 +1,45 @@ +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()); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/RequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/RequiredConstraint.php new file mode 100644 index 00000000..99a5b55a --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/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 (property_exists($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/Draft07/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/TypeConstraint.php new file mode 100644 index 00000000..9601954f --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/TypeConstraint.php @@ -0,0 +1,50 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'type')) { + return; + } + + $schemaTypes = (array) $schema->type; + $valueType = strtolower(gettype($value)); + // 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/src/JsonSchema/Constraints/Drafts/Draft07/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/UniqueItemsConstraint.php new file mode 100644 index 00000000..5363b5c5 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/UniqueItemsConstraint.php @@ -0,0 +1,48 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'uniqueItems')) { + return; + } + if (!is_array($value)) { + 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 5d7e67d0..8041dbaf 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -75,6 +75,7 @@ class Factory 'schema' => 'JsonSchema\Constraints\SchemaConstraint', 'validator' => 'JsonSchema\Validator', 'draft06' => Drafts\Draft06\Draft06Constraint::class, + 'draft07' => Drafts\Draft07\Draft07Constraint::class, ]; /** From 37fb6b3ccccfbb821e2501f2065194f72f8d1bbc Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 15:21:59 +0200 Subject: [PATCH 07/10] feat: Progress draft 7 support --- .../Drafts/Draft07/Draft07Constraint.php | 59 +++++++++-------- .../Constraints/Drafts/Draft07/Factory.php | 1 + .../Drafts/Draft07/IfThenElseConstraint.php | 66 +++++++++++++++++++ tests/JsonSchemaTestSuiteTest.php | 14 ++++ 4 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php index 925f5ba1..663a3039 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php @@ -32,38 +32,39 @@ 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); -// $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); -// $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); -// $this->checkForKeyword('type', $value, $schema, $path, $i); -// $this->checkForKeyword('not', $value, $schema, $path, $i); -// $this->checkForKeyword('dependencies', $value, $schema, $path, $i); -// $this->checkForKeyword('allOf', $value, $schema, $path, $i); -// $this->checkForKeyword('anyOf', $value, $schema, $path, $i); -// $this->checkForKeyword('oneOf', $value, $schema, $path, $i); -// -// $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); -// $this->checkForKeyword('items', $value, $schema, $path, $i); -// $this->checkForKeyword('additionalItems', $value, $schema, $path, $i); + $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); + $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); + $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); + $this->checkForKeyword('type', $value, $schema, $path, $i); + $this->checkForKeyword('not', $value, $schema, $path, $i); + $this->checkForKeyword('dependencies', $value, $schema, $path, $i); + $this->checkForKeyword('allOf', $value, $schema, $path, $i); + $this->checkForKeyword('anyOf', $value, $schema, $path, $i); + $this->checkForKeyword('oneOf', $value, $schema, $path, $i); + $this->checkForKeyword('ifThenElse', $value, $schema, $path, $i); + + $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); -// $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); -// $this->checkForKeyword('maxLength', $value, $schema, $path, $i); -// $this->checkForKeyword('exclusiveMaximum', $value, $schema, $path, $i); -// $this->checkForKeyword('enum', $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('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); + $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); -// $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); -// $this->checkForKeyword('format', $value, $schema, $path, $i); -// $this->checkForKeyword('pattern', $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/Draft07/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php index 913b8ee8..6ab47da4 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php @@ -35,6 +35,7 @@ class Factory extends \JsonSchema\Constraints\Factory 'allOf' => AllOfConstraint::class, 'oneOf' => OneOfConstraint::class, 'not' => NotConstraint::class, + 'ifThenElse' => IfThenElseConstraint::class, 'contains' => ContainsConstraint::class, 'propertyNames' => PropertiesNamesConstraint::class, 'patternProperties' => PatternPropertiesConstraint::class, diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php new file mode 100644 index 00000000..a7076d10 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php @@ -0,0 +1,66 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'if')) { + return; + } + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $ifSchema = $schema->if; + + if (!is_bool($ifSchema)) { + $schemaConstraint->check($value, $ifSchema, $path, $i); + $meetsIfConditions = $schemaConstraint->isValid(); + } else { + $meetsIfConditions = $ifSchema; + } + + if ($meetsIfConditions) { + if (!property_exists($schema, 'then')) { + return; + } + + $schemaConstraint->check($value, $schema->then, $path, $i); + if ($schemaConstraint->isValid()) { + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + return; + } + + if (!property_exists($schema, 'else')) { + return; + } + + $schemaConstraint->check($value, $schema->else, $path, $i); + if ($schemaConstraint->isValid()) { + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + } +} diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 46b5c44e..52e35283 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -174,6 +174,20 @@ private function shouldNotYieldTest(string $name): bool '[draft6/ref.json]: URN base URI with r-component: a non-string is invalid is expected to be invalid', '[draft6/ref.json]: URN base URI with q-component: a non-string is invalid is expected to be invalid', '[draft6/ref.json]: URN base URI with URN and anchor ref: a non-string is invalid is expected to be invalid', + '[draft7/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches second anyOf, which has a real schema in it is expected to be valid', + '[draft7/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches non-schema in third anyOf is expected to be invalid', + '[draft7/refRemote.json]: $ref to $ref finds location-independent $id: non-number is invalid is expected to be invalid', + '[draft7/ref.json]: ref overrides any sibling keywords: ref valid, maxItems ignored is expected to be valid', + '[draft7/ref.json]: Reference an anchor with a non-relative URI: mismatch is expected to be invalid', + '[draft7/ref.json]: refs with relative uris and defs: invalid on inner field is expected to be invalid', + '[draft7/ref.json]: refs with relative uris and defs: invalid on outer field is expected to be invalid', + '[draft7/ref.json]: relative refs with absolute uris and defs: invalid on inner field is expected to be invalid', + '[draft7/ref.json]: relative refs with absolute uris and defs: invalid on outer field is expected to be invalid', + '[draft7/ref.json]: simple URN base URI with JSON pointer: a non-string is invalid is expected to be invalid', + '[draft7/ref.json]: URN base URI with NSS: a non-string is invalid is expected to be invalid', + '[draft7/ref.json]: URN base URI with r-component: a non-string is invalid is expected to be invalid', + '[draft7/ref.json]: URN base URI with q-component: a non-string is invalid is expected to be invalid', + '[draft7/ref.json]: URN base URI with URN and anchor ref: a non-string is invalid is expected to be invalid', ]; if ($this->is32Bit()) { From 98353d07e310822b43673ab35bc5b794797e8692 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 15:35:50 +0200 Subject: [PATCH 08/10] fix: Reset schema constraint after if evaluation --- .../Constraints/Drafts/Draft07/IfThenElseConstraint.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php index a7076d10..a7d8a4a8 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php @@ -34,6 +34,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n if (!is_bool($ifSchema)) { $schemaConstraint->check($value, $ifSchema, $path, $i); $meetsIfConditions = $schemaConstraint->isValid(); + $schemaConstraint->reset(); } else { $meetsIfConditions = $ifSchema; } From b0030d10cfdb50ff5c98affc6fd8fcf8e528f77f Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 15:38:18 +0200 Subject: [PATCH 09/10] style: Correct code style violations --- .../Constraints/Drafts/Draft07/IfThenElseConstraint.php | 2 +- src/JsonSchema/DraftIdentifiers.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php index a7d8a4a8..d3fe82c7 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/IfThenElseConstraint.php @@ -4,7 +4,6 @@ namespace JsonSchema\Constraints\Drafts\Draft07; -use JsonSchema\ConstraintError; use JsonSchema\Constraints\ConstraintInterface; use JsonSchema\Entity\ErrorBagProxy; use JsonSchema\Entity\JsonPointer; @@ -50,6 +49,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } $this->addErrors($schemaConstraint->getErrors()); + return; } diff --git a/src/JsonSchema/DraftIdentifiers.php b/src/JsonSchema/DraftIdentifiers.php index c8f82718..e062b6a8 100644 --- a/src/JsonSchema/DraftIdentifiers.php +++ b/src/JsonSchema/DraftIdentifiers.php @@ -21,7 +21,7 @@ class DraftIdentifiers extends Enum public const DRAFT_2019_09 = 'https://json-schema.org/draft/2019-09/schema'; public const DRAFT_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; - /** @var array */ + /** @var array */ private const MAPPING = [ self::DRAFT_3 => 'draft03', self::DRAFT_4 => 'draft04', From 8ecd057a0f72b026b4330e527e27d9f55b948891 Mon Sep 17 00:00:00 2001 From: Danny van der Sluijs Date: Fri, 10 Oct 2025 16:32:23 +0200 Subject: [PATCH 10/10] feat: Add content validation support --- src/JsonSchema/ConstraintError.php | 6 +++++- .../Constraints/Drafts/Draft07/Draft07Constraint.php | 1 + src/JsonSchema/Constraints/Drafts/Draft07/Factory.php | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index ed6cf03e..5fcfaf39 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -58,6 +58,8 @@ class ConstraintError extends Enum public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; + public const CONTENT_MEDIA_TYPE = 'contentMediaType'; + public const CONTENT_ENCODING = 'contentEncoding'; /** * @return string @@ -115,7 +117,9 @@ public function getMessage() 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' + self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array', + self::CONTENT_MEDIA_TYPE => 'Value is not valid with content media type', + self::CONTENT_ENCODING => 'Value is not valid with content encoding', ]; if (!isset($messages[$name])) { diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php index 663a3039..084ce96c 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Draft07Constraint.php @@ -65,6 +65,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); $this->checkForKeyword('format', $value, $schema, $path, $i); $this->checkForKeyword('pattern', $value, $schema, $path, $i); + $this->checkForKeyword('content', $value, $schema, $path, $i); } /** diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php index 6ab47da4..cb559952 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/Factory.php @@ -43,5 +43,6 @@ class Factory extends \JsonSchema\Constraints\Factory 'properties' => PropertiesConstraint::class, 'items' => ItemsConstraint::class, 'ref' => RefConstraint::class, + 'content' => ContentConstraint::class, ]; }