diff --git a/README.md b/README.md index 5fbb24fd..02a86f94 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Supported Dialects](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fsupported_versions.json) A PHP Implementation for validating `JSON` Structures against a given `Schema` with support for `Schemas` of Draft-3, -Draft-4, Draft-6 or Draft-7. +Draft-4, Draft-6,Draft-7 or Draft 2019-09. Features of newer Drafts might not be supported. See [Table of All Versions of Everything](https://json-schema.org/specification-links.html#table-of-all-versions-of-everything) to get an overview of all existing Drafts. See [json-schema](http://json-schema.org/) for more details about the JSON Schema specification @@ -15,7 +15,8 @@ of all existing Drafts. See [json-schema](http://json-schema.org/) for more deta ![Draft 3](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft3.json) ![Draft 4](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft4.json) ![Draft 6](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft6.json) -![Draft 7](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft7.json) +![Draft 7](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fcompliance%2Fdraft7.json))) +![Draft 2019-09](https://img.shields.io/endpoint?url=https%3A%2F%2Fbowtie.report%2Fbadges%2Fphp-justinrainbow-json-schema%2Fdraft2019-09.json) ## Installation @@ -215,7 +216,7 @@ original data. [^2]: `CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If enabled, the validator will use (and coerce) the first compatible type it encounters, even if the schema defines another type that matches directly and does not require coercion. -[^3]: `CHECK_MODE_STRICT` only can be used for Draft-6 at this point. +[^3]: `CHECK_MODE_STRICT` only can be used for Draft-6, Draft 7 or Draft 2019-09 at this point. ## Running the tests diff --git a/composer.json b/composer.json index 5dfe65d1..e975e178 100644 --- a/composer.json +++ b/composer.json @@ -33,11 +33,11 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", "phpunit/phpunit": "^8.5", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", - "marc-mabe/php-enum-phpstan": "^2.0" + "marc-mabe/php-enum-phpstan": "^2.0", + "json-schema/json-schema-test-suite": "dev-main" }, "extra": { "branch-alias": { @@ -59,11 +59,11 @@ "type": "package", "package": { "name": "json-schema/json-schema-test-suite", - "version": "23.2.0", + "version": "dev-main", "source": { "type": "git", "url": "https://github.com/json-schema/JSON-Schema-Test-Suite", - "reference": "23.2.0" + "reference": "main" } } } diff --git a/dist/schema/json-schema-draft-2019-09.json b/dist/schema/json-schema-draft-2019-09.json new file mode 100644 index 00000000..bc67d49b --- /dev/null +++ b/dist/schema/json-schema-draft-2019-09.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/schema", + "title": "Core and Validation specifications meta-schema", + "type": ["object", "boolean"], + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/$defs/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "properties": { + "$id": { "type": "string", "format": "uri-reference" }, + "$schema": { "type": "string", "format": "uri" }, + "$anchor": { "type": "string", "pattern": "^[A-Za-z][-A-Za-z0-9.:_]*$" }, + "$ref": { "type": "string", "format": "uri-reference" }, + "$recursiveRef": { "type": "string", "format": "uri-reference" }, + "$recursiveAnchor": { "type": "boolean", "default": false }, + "$vocabulary": { + "type": "object", + "propertyNames": { "type": "string", "format": "uri" }, + "additionalProperties": { "type": "boolean" } + }, + "$comment": { "type": "string" }, + "$defs": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "definitions": { + "$comment": "Retained for backwards compatibility, replaced by $defs.", + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": true, + "deprecated": { "type": "boolean", "default": false }, + "readOnly": { "type": "boolean", "default": false }, + "writeOnly": { "type": "boolean", "default": false }, + "examples": { "type": "array", "items": true }, + "multipleOf": { "type": "number", "exclusiveMinimum": 0 }, + "maximum": { "type": "number" }, + "exclusiveMaximum": { "type": "number" }, + "minimum": { "type": "number" }, + "exclusiveMinimum": { "type": "number" }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { "type": "string", "format": "regex" }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/$defs/schemaArray" } + ], + "default": true + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { "type": "boolean", "default": false }, + "contains": { "$ref": "#" }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "unevaluatedItems": { "$ref": "#" }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "unevaluatedProperties": { "$ref": "#" }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$ref": "#" } + }, + "dependentRequired": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/stringArray" } + }, + "dependencies": { + "$comment": "Retained for backwards compatibility, replaced by dependentSchemas/dependentRequired.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/$defs/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$ref": "#" }, + "if": { "$ref": "#" }, + "then": { "$ref": "#" }, + "else": { "$ref": "#" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": true +} \ No newline at end of file diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index 5fcfaf39..081e38ad 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -39,7 +39,9 @@ class ConstraintError extends Enum public const INVALID_SCHEMA = 'invalidSchema'; public const LENGTH_MAX = 'maxLength'; public const LENGTH_MIN = 'minLength'; + public const MAX_CONTAINS = 'maxContains'; public const MAXIMUM = 'maximum'; + public const MIN_CONTAINS = 'minContains'; public const MIN_ITEMS = 'minItems'; public const MINIMUM = 'minimum'; public const MISSING_ERROR = 'missingError'; @@ -99,8 +101,10 @@ public function getMessage() self::LENGTH_MAX => 'Must be at most %d characters long', self::INVALID_SCHEMA => 'Schema is not valid', self::LENGTH_MIN => 'Must be at least %d characters long', + self::MAX_CONTAINS => 'There must be a maximum of %d valid items in the array, %d found', self::MAX_ITEMS => 'There must be a maximum of %d items in the array, %d found', self::MAXIMUM => 'Must have a maximum value less than or equal to %d', + self::MIN_CONTAINS => 'There must be a minimum of %d valid items in the array, %d found', self::MIN_ITEMS => 'There must be a minimum of %d items in the array, %d found', self::MINIMUM => 'Must have a minimum value greater than or equal to %d', self::MISSING_MAXIMUM => 'Use of exclusiveMaximum requires presence of maximum', diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php index 5f244896..5d6c55cb 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -66,7 +66,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } foreach ($additionalProperties as $key => $additionalPropertiesValue) { - $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $additionalPropertiesValue]); + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $key]); } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php index 578a27c3..ec61b73b 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -155,7 +155,7 @@ private function validateColor(string $color): bool return true; } - return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) !== false; + return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) === 1; } private function validateStyle(string $style): bool @@ -168,7 +168,7 @@ private function validateStyle(string $style): bool private function validatePhone(string $phone): bool { - return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false; + return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) === 1; } private function validateHostname(string $host): bool diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php index d621ecf2..96312cef 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php @@ -61,5 +61,20 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } } + + if (property_exists($schema->propertyNames, 'const')) { + foreach ($propertyNames as $propertyName => $_) { + if ($propertyName !== $schema->propertyNames->const) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'const', 'name' => $propertyName]); + } + } + } + + if (property_exists($schema->propertyNames, 'enum')) { + $diff = array_diff(array_keys($propertyNames), $schema->propertyNames->enum); + foreach ($diff as $propertyName) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'enum', 'name' => $propertyName]); + } + } } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php index 27d5c4f6..7162c11b 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/AdditionalPropertiesConstraint.php @@ -66,7 +66,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } foreach ($additionalProperties as $key => $additionalPropertiesValue) { - $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $additionalPropertiesValue]); + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $key]); } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php index 30f6c1c8..8ddc879d 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php @@ -208,7 +208,7 @@ private function validateColor(string $color): bool return true; } - return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) !== false; + return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) === 1; } private function validateStyle(string $style): bool @@ -221,7 +221,7 @@ private function validateStyle(string $style): bool private function validatePhone(string $phone): bool { - return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false; + return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) === 1; } private function validateHostname(string $host): bool diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php index 7bf9ea74..e3838990 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/PropertiesNamesConstraint.php @@ -61,5 +61,20 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } } } + + if (property_exists($schema->propertyNames, 'const')) { + foreach ($propertyNames as $propertyName => $_) { + if ($propertyName !== $schema->propertyNames->const) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'const', 'name' => $propertyName]); + } + } + } + + if (property_exists($schema->propertyNames, 'enum')) { + $diff = array_diff(array_keys($propertyNames), $schema->propertyNames->enum); + foreach ($diff as $propertyName) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'enum', 'name' => $propertyName]); + } + } } } diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalItemsConstraint.php new file mode 100644 index 00000000..79079c37 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalItemsConstraint.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, '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 (is_object($schema->items)) { + return; + } + + $additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []); + $basePath = $path ?? new JsonPointer(''); + + foreach ($additionalItems as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $schema->additionalItems, $incrementedPath, $i); + + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $incrementedPath, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/AdditionalPropertiesConstraint.php new file mode 100644 index 00000000..9406c477 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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' => $key]); + } + } + + 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/Draft2019/AllOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/AllOfConstraint.php new file mode 100644 index 00000000..42d29d39 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/AnyOfConstraint.php new file mode 100644 index 00000000..f0b66049 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/ConstConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ConstConstraint.php new file mode 100644 index 00000000..9cfacf87 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ContainsConstraint.php new file mode 100644 index 00000000..3e0e5a1e --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/ContainsConstraint.php @@ -0,0 +1,54 @@ +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; + } + + if (!is_array($value)) { + return; + } + + $validElementCount = 0; + foreach ($value as $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + $schemaConstraint->check($propertyValue, $schema->contains, $path, $i); + if ($schemaConstraint->isValid()) { + $validElementCount++; + } + } + + if (property_exists($schema, 'maxContains') && $validElementCount > $schema->maxContains) { + $this->addError(ConstraintError::MAX_CONTAINS(), $path, ['maxContains' => $schema->maxContains, 'count' => $validElementCount]); + } + + $minContains = property_exists($schema, 'minContains') ? $schema->minContains : 1; + if ($validElementCount < $minContains) { + $this->addError(ConstraintError::MIN_CONTAINS(), $path, ['minContains' => $minContains, 'count' => $validElementCount]); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/ContentConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ContentConstraint.php new file mode 100644 index 00000000..ed3c4903 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/ContentConstraint.php @@ -0,0 +1,25 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + // See https://json-schema.org/draft/2019-09/json-schema-validation#rfc.section.8 + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/DependentRequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/DependentRequiredConstraint.php new file mode 100644 index 00000000..a1cf42ff --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/DependentRequiredConstraint.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, 'dependentRequired')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->dependentRequired as $dependant => $dependencies) { + if (!property_exists($value, $dependant)) { + continue; + } + + foreach ($dependencies as $dependency) { + if (!property_exists($value, $dependency)) { + $this->addError(ConstraintError::DEPENDENCIES(), $path, [ + 'dependant' => $dependant, + 'dependency' => $dependency, + ]); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/DependentSchemasConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/DependentSchemasConstraint.php new file mode 100644 index 00000000..0b469c65 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/DependentSchemasConstraint.php @@ -0,0 +1,58 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'dependentSchemas')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->dependentSchemas as $dependant => $dependentSchema) { + if (!property_exists($value, $dependant)) { + continue; + } + + if ($dependentSchema === true) { + continue; + } + + if ($dependentSchema === false) { + $this->addError(ConstraintError::FALSE(), $path, ['dependant' => $dependant]); + continue; + } + + if (is_object($dependentSchema)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $dependentSchema, $path, $i); + if (!$schemaConstraint->isValid()) { + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php new file mode 100644 index 00000000..0b003b54 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php @@ -0,0 +1,84 @@ +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('dependentSchemas', $value, $schema, $path, $i); + $this->checkForKeyword('dependentRequired', $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('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('content', $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/Draft2019/EnumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/EnumConstraint.php new file mode 100644 index 00000000..aba26371 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/ExclusiveMaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ExclusiveMaximumConstraint.php new file mode 100644 index 00000000..0241197b --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/ExclusiveMinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ExclusiveMinimumConstraint.php new file mode 100644 index 00000000..9c9e7a32 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php new file mode 100644 index 00000000..cc490225 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php @@ -0,0 +1,49 @@ + + */ + protected $constraintMap = [ + 'schema' => Draft2019Constraint::class, + 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'additionalItems' => AdditionalItemsConstraint::class, + 'dependentSchemas' => DependentSchemasConstraint::class, + 'dependentRequired' => DependentRequiredConstraint::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, + 'ifThenElse' => IfThenElseConstraint::class, + 'contains' => ContainsConstraint::class, + 'propertyNames' => PropertiesNamesConstraint::class, + 'patternProperties' => PatternPropertiesConstraint::class, + 'pattern' => PatternConstraint::class, + 'properties' => PropertiesConstraint::class, + 'items' => ItemsConstraint::class, + 'ref' => RefConstraint::class, + 'content' => ContentConstraint::class, + ]; +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php new file mode 100644 index 00000000..5fb47c87 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/FormatConstraint.php @@ -0,0 +1,337 @@ +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:sp') && !$this->validateDateTime($value, 'H:i:s.up')) { + $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 'idn-hostname': + if (!$this->validateInternationalizedHostname($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 + { + $datetime = strtoupper($datetime); // Cleanup for lowercase z + $isLeap = substr($datetime, 6, 2) === '60'; + $input = $datetime; + + // Correct for leap second + if ($isLeap) { + $input = sprintf('%s59%s', substr($datetime, 0, 6), substr($datetime, 8)); + } + + $dt = \DateTimeImmutable::createFromFormat($format, $input); + if (!$dt) { + return false; + } + + // Handle invalid timezone offsets + $timezoneOffset = $dt->getTimezone()->getOffset($dt); + if ($timezoneOffset >= 86400 || $timezoneOffset <= -86400) { + return false; + } + + $expected = $dt->format($format); + // Correct for trailing zeros on microseconds + if ($format === 'H:i:s.up') { + $expected = sprintf( + '%s%s', + rtrim($dt->format('H:i:s.u'), '0'), + $dt->format('p') + ); + } + // Correct back for leap seconds + if ($isLeap) { + // Only when 23:59:59 in UTC + $utcDT = $dt->setTimezone(new DateTimeZone('UTC')); + if ($utcDT->format('H:i:s') !== '23:59:59') { + return false; + } + + $expected = sprintf('%s60%s', substr($expected, 0, 6), substr($expected, 8)); + } + + return $datetime === $expected; + } + + 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) === 1; + } + + 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) === 1; + } + + 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 validateInternationalizedHostname(string $host): bool + { + if ($host === '') { + return false; + } + $host = rtrim($host, '.'); + $labels = explode('.', $host); + $asciiLabels = []; + + foreach ($labels as $label) { + if ($label === '') { + return false; + } + + // CONTEXTJ / CONTEXTO checks + if ( + // Greek KERAIA U+0375 + preg_match('/\x{0375}/u', $label) && + !preg_match('/\x{0375}[\x{0370}-\x{03FF}]/u', $label) + ) { + return false; + } + + // Hebrew GERESH / GERSHAYIM U+05F3 / U+05F4 + if (preg_match('/[\x{05F3}\x{05F4}]/u', $label) && + !preg_match('/[\x{0590}-\x{05FF}][\x{05F3}\x{05F4}]/u', $label) + ) { + return false; + } + + // Katakana middle dot U+30FB + if (str_contains($label, "\u{30FB}") && + !preg_match('/[\x{30A0}-\x{30FF}]/u', $label) + ) { + return false; + } + + // Arabic digit mixing + $hasArabicIndic = preg_match('/[\x{0660}-\x{0669}]/u', $label); + $hasExtArabicIndic = preg_match('/[\x{06F0}-\x{06F9}]/u', $label); + if ($hasArabicIndic && $hasExtArabicIndic) { + return false; + } + + // Devanagari danda U+0964 / U+0965 + if (preg_match('/[\x{0964}\x{0965}]/u', $label) && + !preg_match('/[\x{0900}-\x{097F}]/u', $label) + ) { + return false; + } + + // ZWNJ / ZWJ U+200C / U+200D + if (preg_match('/[\x{200C}\x{200D}]/u', $label)) { + return false; + } + + $ascii = idn_to_ascii($label); + if ($ascii === false) { + return false; + } + // DNS label length + if (strlen($ascii) > 63) { + return false; + } + // LDH rule (after IDNA) + if (!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $ascii)) { + return false; + } + $asciiLabels[] = $ascii; + } + + // Total hostname length (ASCII) + $asciiHost = implode('.', $asciiLabels); + + return strlen($asciiHost) <= 253; + } + + 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/Draft2019/IfThenElseConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/IfThenElseConstraint.php new file mode 100644 index 00000000..a9451420 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/IfThenElseConstraint.php @@ -0,0 +1,67 @@ +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(); + $schemaConstraint->reset(); + } 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/src/JsonSchema/Constraints/Drafts/Draft2019/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/ItemsConstraint.php new file mode 100644 index 00000000..38abb3df --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/ItemsConstraint.php @@ -0,0 +1,54 @@ +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; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($value as $propertyName => $propertyValue) { + $itemSchema = $schema->items; + if (is_array($itemSchema)) { + if (!array_key_exists($propertyName, $itemSchema)) { + continue; + } + + $itemSchema = $itemSchema[$propertyName]; + } + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $itemSchema, $incrementedPath, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/MaxItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MaxItemsConstraint.php new file mode 100644 index 00000000..fd5c45be --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MaxLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MaxLengthConstraint.php new file mode 100644 index 00000000..17eee392 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MaxPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MaxPropertiesConstraint.php new file mode 100644 index 00000000..8bae235e --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MaximumConstraint.php new file mode 100644 index 00000000..3e10ee52 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MinItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MinItemsConstraint.php new file mode 100644 index 00000000..f26082c0 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MinLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MinLengthConstraint.php new file mode 100644 index 00000000..9784c39f --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MinPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MinPropertiesConstraint.php new file mode 100644 index 00000000..ea4dd5d7 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MinimumConstraint.php new file mode 100644 index 00000000..b0a8ab8e --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/MultipleOfConstraint.php new file mode 100644 index 00000000..0b33601d --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/NotConstraint.php new file mode 100644 index 00000000..8b9b40bd --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/OneOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/OneOfConstraint.php new file mode 100644 index 00000000..fd93cefe --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/PatternConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/PatternConstraint.php new file mode 100644 index 00000000..b36ec026 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php new file mode 100644 index 00000000..44156154 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/PatternPropertiesConstraint.php @@ -0,0 +1,75 @@ +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); + + $basePath = $path ?? new JsonPointer(''); + foreach ($properties as $propertyName => $propertyValue) { + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + 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, $incrementedPath, $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/Draft2019/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/PropertiesConstraint.php new file mode 100644 index 00000000..4052b1b9 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/PropertiesConstraint.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, 'properties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($schema->properties as $propertyName => $propertySchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + if (!property_exists($value, $propertyName)) { + continue; + } + + $incrementedPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + $schemaConstraint->check($value->{$propertyName}, $propertySchema, $incrementedPath, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/PropertiesNamesConstraint.php new file mode 100644 index 00000000..1ce7f0c4 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/PropertiesNamesConstraint.php @@ -0,0 +1,80 @@ +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]); + } + } + } + + if (property_exists($schema->propertyNames, 'const')) { + foreach ($propertyNames as $propertyName => $_) { + if ($propertyName !== $schema->propertyNames->const) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'const', 'name' => $propertyName]); + } + } + } + + if (property_exists($schema->propertyNames, 'enum')) { + $diff = array_diff(array_keys($propertyNames), $schema->propertyNames->enum); + foreach ($diff as $propertyName) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'enum', 'name' => $propertyName]); + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/RefConstraint.php new file mode 100644 index 00000000..989b1f03 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/RequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/RequiredConstraint.php new file mode 100644 index 00000000..a5c4c93a --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/TypeConstraint.php new file mode 100644 index 00000000..e9ba31dd --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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/Draft2019/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UniqueItemsConstraint.php new file mode 100644 index 00000000..9406a272 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/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 8041dbaf..6cbefcef 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -76,6 +76,7 @@ class Factory 'validator' => 'JsonSchema\Validator', 'draft06' => Drafts\Draft06\Draft06Constraint::class, 'draft07' => Drafts\Draft07\Draft07Constraint::class, + 'draft2019-09' => Drafts\Draft2019\Draft2019Constraint::class, ]; /** diff --git a/src/JsonSchema/Entity/JsonPointer.php b/src/JsonSchema/Entity/JsonPointer.php index e674fa6f..e33866b6 100644 --- a/src/JsonSchema/Entity/JsonPointer.php +++ b/src/JsonSchema/Entity/JsonPointer.php @@ -122,7 +122,7 @@ public function getPropertyPaths() public function withPropertyPaths(array $propertyPaths) { $new = clone $this; - $new->propertyPaths = array_map(function ($p): string { return (string) $p; }, $propertyPaths); + $new->propertyPaths = array_map(static function ($p): string { return (string) $p; }, $propertyPaths); return $new; } diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index 3e9a71c1..76f1c772 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -32,7 +32,8 @@ class UriRetriever implements BaseUriRetrieverInterface */ protected $translationMap = [ // use local copies of the spec schemas - '|^https?://json-schema.org/draft-(0[3467])/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', + '|^https://json-schema.org/draft/2019-09/schema#?|' => 'package://dist/schema/json-schema-draft-2019-09.json' ]; /** diff --git a/tests/Constraints/AdditionalPropertiesTest.php b/tests/Constraints/AdditionalPropertiesTest.php index a4753cde..5d7791bd 100644 --- a/tests/Constraints/AdditionalPropertiesTest.php +++ b/tests/Constraints/AdditionalPropertiesTest.php @@ -4,6 +4,7 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\Constraints\Constraint; use JsonSchema\Validator; class AdditionalPropertiesTest extends BaseTestCase @@ -105,6 +106,60 @@ public function getInvalidTests(): \Generator "additionalProperties": false }' ]; + yield 'Draft 6: error message contains property name not value' => [ + '{ + "knownProp": "hello", + "extraProp": 42 + }', + '{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "knownProp": {"type": "string"} + }, + "additionalProperties": false + }', + Constraint::CHECK_MODE_STRICT, + [ + [ + 'property' => '', + 'pointer' => '', + 'message' => 'The property extraProp is not defined and the definition does not allow additional properties', + 'constraint' => [ + 'name' => 'additionalProp', + 'params' => ['found' => 'extraProp'] + ], + 'context' => Validator::ERROR_DOCUMENT_VALIDATION + ] + ] + ]; + yield 'Draft 7: error message contains property name not value' => [ + '{ + "knownProp": "hello", + "extraProp": 42 + }', + '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "knownProp": {"type": "string"} + }, + "additionalProperties": false + }', + Constraint::CHECK_MODE_STRICT, + [ + [ + 'property' => '', + 'pointer' => '', + 'message' => 'The property extraProp is not defined and the definition does not allow additional properties', + 'constraint' => [ + 'name' => 'additionalProp', + 'params' => ['found' => 'extraProp'] + ], + 'context' => Validator::ERROR_DOCUMENT_VALIDATION + ] + ] + ]; } public function getValidTests(): \Generator diff --git a/tests/Constraints/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index 1c209085..2af65ea4 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -43,6 +43,9 @@ protected function getUriRetrieverMock($schema): object if (strpos($args[0], DraftIdentifiers::DRAFT_6()->withoutFragment()) === 0) { return $that->getDraftSchema('json-schema-draft-06.json'); } + if (strpos($args[0], DraftIdentifiers::DRAFT_7()->withoutFragment()) === 0) { + return $that->getDraftSchema('json-schema-draft-07.json'); + } $urlParts = parse_url($args[0]); diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index aec88cd6..c765f7d0 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -55,7 +55,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 = ['draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { $baseDraftName = basename($draft); @@ -178,6 +178,119 @@ private function shouldNotYieldTest(string $name): bool '[draft7/ref.json]: Location-independent identifier: mismatch is expected to be invalid', '[draft7/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', '[draft7/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', + // Draft 2019-09 complex constraints, which aren't supported initially + '[draft2019-09/recursiveRef.json]: $recursiveRef without $recursiveAnchor works like $ref: recursive mismatch is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef without using nesting: integer does not match as a property value is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef without using nesting: two levels, no match is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with $recursiveAnchor: false works like $ref: integer does not match as a property value is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with $recursiveAnchor: false works like $ref: two levels, integer does not match as a property value is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with no $recursiveAnchor works like $ref: integer does not match as a property value is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with no $recursiveAnchor works like $ref: two levels, integer does not match as a property value is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with no $recursiveAnchor in the outer schema resource: leaf node does not match: recursion only uses inner schema is expected to be invalid', + '[draft2019-09/recursiveRef.json]: $recursiveRef with no $recursiveAnchor in the initial target schema resource: leaf node does not match: recursion uses the inner schema is expected to be invalid', + '[draft2019-09/recursiveRef.json]: multiple dynamic paths to the $recursiveRef keyword: recurse to integerNode - floats are not allowed is expected to be invalid', + '[draft2019-09/recursiveRef.json]: dynamic $recursiveRef destination (not predictable at schema compile time): integer node is expected to be invalid', + '[draft2019-09/vocabulary.json]: schema that uses custom metaschema with with no validation vocabulary: applicator vocabulary still works is expected to be invalid', + '[draft2019-09/vocabulary.json]: schema that uses custom metaschema with with no validation vocabulary: no validation: valid number is expected to be valid', + '[draft2019-09/vocabulary.json]: schema that uses custom metaschema with with no validation vocabulary: no validation: invalid number, but it still validates is expected to be valid', + '[draft2019-09/vocabulary.json]: ignore unrecognized optional vocabulary: string value is expected to be invalid', + '[draft2019-09/vocabulary.json]: ignore unrecognized optional vocabulary: number value is expected to be valid', + '[draft2019-09/defs.json]: validate definition against metaschema: invalid definition schema is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier name is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier name and no ref is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier path is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier name with absolute URI is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier path with absolute URI is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier name with base URI change in subschema is expected to be invalid', + '[draft2019-09/id.json]: Invalid use of fragments in location-independent $id: Identifier path with base URI change in subschema is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties schema: with invalid unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties false: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with adjacent properties: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with adjacent patternProperties: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with nested properties: with additional properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with nested patternProperties: with additional properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with anyOf: when one matches and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with anyOf: when two match and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with oneOf: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with not: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else: when if is true and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else: when if is false and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, then not defined: when if is true and has no unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, then not defined: when if is true and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, then not defined: when if is false and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, else not defined: when if is true and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, else not defined: when if is false and has no unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with if/then/else, else not defined: when if is false and has unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with dependentSchemas: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with boolean schemas: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties with $ref: with unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties can\'t see inside cousins: always fails is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties can\'t see inside cousins (reverse order): always fails is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: nested unevaluatedProperties, outer true, inner false, properties outside: with no nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: nested unevaluatedProperties, outer true, inner false, properties outside: with nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: nested unevaluatedProperties, outer true, inner false, properties inside: with nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: cousin unevaluatedProperties, true and false, true with properties: with no nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: cousin unevaluatedProperties, true and false, true with properties: with nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: cousin unevaluatedProperties, true and false, false with properties: with nested unevaluated properties is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: property is evaluated in an uncle schema to unevaluatedProperties: uncle keyword evaluation is not significant is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: in-place applicator siblings, allOf has unevaluated: base case: both properties present is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: in-place applicator siblings, allOf has unevaluated: in place applicator siblings, foo is missing is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: in-place applicator siblings, anyOf has unevaluated: base case: both properties present is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: in-place applicator siblings, anyOf has unevaluated: in place applicator siblings, bar is missing is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties + single cyclic ref: Unevaluated on 1st level is invalid is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties + single cyclic ref: Unevaluated on 2nd level is invalid is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties + single cyclic ref: Unevaluated on 3rd level is invalid is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: dynamic evalation inside nested refs: xx + foo is invalid is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties not affected by propertyNames: string property is invalid is expected to be invalid', + '[draft2019-09/unevaluatedProperties.json]: unevaluatedProperties can see annotations from if without then and else: invalid in case if is evaluated is expected to be invalid', + '[draft2019-09/anchor.json]: Location-independent identifier: mismatch is expected to be invalid', + '[draft2019-09/anchor.json]: Location-independent identifier with absolute URI: mismatch is expected to be invalid', + '[draft2019-09/anchor.json]: Location-independent identifier with base URI change in subschema: mismatch is expected to be invalid', + '[draft2019-09/anchor.json]: $anchor inside an enum is not a real identifier: in implementations that strip $anchor, this may match either $def is expected to be invalid', + '[draft2019-09/anchor.json]: $anchor inside an enum is not a real identifier: no match on enum or $ref to $anchor is expected to be invalid', + '[draft2019-09/anchor.json]: same $anchor with different base uri: $ref does not resolve to /$defs/A/allOf/0 is expected to be invalid', + '[draft2019-09/ref.json]: ref creates new scope when adjacent to keywords: referenced subschema doesn\'t see annotations from properties is expected to be invalid', + '[draft2019-09/ref.json]: refs with relative uris and defs: invalid on inner field is expected to be invalid', + '[draft2019-09/ref.json]: refs with relative uris and defs: invalid on outer field is expected to be invalid', + '[draft2019-09/ref.json]: relative refs with absolute uris and defs: invalid on inner field is expected to be invalid', + '[draft2019-09/ref.json]: relative refs with absolute uris and defs: invalid on outer field is expected to be invalid', + '[draft2019-09/ref.json]: $id must be resolved against nearest parent, not just immediate parent: non-number is invalid is expected to be invalid', + '[draft2019-09/ref.json]: order of evaluation: $id and $ref: data is invalid against first definition is expected to be invalid', + '[draft2019-09/ref.json]: order of evaluation: $id and $anchor and $ref: data is invalid against first definition is expected to be invalid', + '[draft2019-09/ref.json]: simple URN base URI with JSON pointer: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: URN base URI with NSS: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: URN base URI with r-component: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: URN base URI with q-component: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: URN base URI with URN and anchor ref: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: URN ref with nested pointer ref: a non-string is invalid is expected to be invalid', + '[draft2019-09/ref.json]: ref with absolute-path-reference: an integer is invalid is expected to be invalid', + '[draft2019-09/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', + '[draft2019-09/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches non-schema in third anyOf is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems false: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems as schema: with invalid unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with tuple: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with ignored additionalItems: invalid under unevaluatedItems is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with ignored applicator additionalItems: invalid under unevaluatedItems is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with nested tuple: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with nested items: with invalid additional item is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with anyOf: when one schema matches and has unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with anyOf: when two schemas match and has unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with oneOf: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with not: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with if/then/else: when if matches and it has unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with if/then/else: when if doesn\'t match and it has unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with boolean schemas: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems with $ref: with unevaluated items is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems can\'t see inside cousins: always fails is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: item is evaluated in an uncle schema to unevaluatedItems: uncle keyword evaluation is not significant is expected to be invalid', + '[draft2019-09/unevaluatedItems.json]: unevaluatedItems can see annotations from if without then and else: invalid in case if is evaluated is expected to be invalid', + '[draft2019-09/not.json]: collect annotations inside a \'not\', even if collection is disabled: unevaluated property is expected to be valid', + '[draft2019-09/refRemote.json]: anchor within remote ref: remote anchor invalid is expected to be invalid', + '[draft2019-09/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', + '[draft2019-09/refRemote.json]: base URI change - change folder in subschema: string is invalid is expected to be invalid', + '[draft2019-09/refRemote.json]: remote ref with ref to defs: invalid is expected to be invalid', + '[draft2019-09/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', + '[draft2019-09/refRemote.json]: $ref to $ref finds detached $anchor: non-number is invalid is expected to be invalid', // Optional: bignum — PHP does not natively support arbitrary-precision integers/floats '[draft3/optional/bignum.json]: integer: a bignum is an integer is expected to be valid', '[draft3/optional/bignum.json]: integer: a negative bignum is an integer is expected to be valid', @@ -287,13 +400,14 @@ private function is32Bit(): bool } /** - * @phpstan-return int-mask-of + * @phpstan-return int-mask-of */ private function getCheckModeForDraft(string $draft): int { switch ($draft) { case 'draft6': case 'draft7': + case 'draft2019-09': return Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT; default: return Constraint::CHECK_MODE_NORMAL; diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index 9dabbc4b..c0016664 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -89,6 +89,7 @@ public function draftIdentifiersNotSupportedForStrictMode(): \Generator switch ($draft) { case DraftIdentifiers::DRAFT_6(): case DraftIdentifiers::DRAFT_7(): + case DraftIdentifiers::DRAFT_2019_09(): break; default: yield $draft->toConstraintName() => [$draft];