diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index 081e38ad..07b489a9 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -60,6 +60,7 @@ class ConstraintError extends Enum public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; + public const UNEVALUATED_PROPERTIES = 'unevaluatedProperties'; public const CONTENT_MEDIA_TYPE = 'contentMediaType'; public const CONTENT_ENCODING = 'contentEncoding'; @@ -122,6 +123,7 @@ public function getMessage() 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::UNEVALUATED_PROPERTIES => 'The property %s is not evaluated and the definition does not allow unevaluated properties', self::CONTENT_MEDIA_TYPE => 'Value is not valid with content media type', self::CONTENT_ENCODING => 'Value is not valid with content encoding', ]; diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php index 0b003b54..da459bb7 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php @@ -46,6 +46,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('anyOf', $value, $schema, $path, $i); $this->checkForKeyword('oneOf', $value, $schema, $path, $i); $this->checkForKeyword('ifThenElse', $value, $schema, $path, $i); + $this->checkForKeyword('unevaluatedProperties', $value, $schema, $path, $i); $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); $this->checkForKeyword('items', $value, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php index cc490225..0c8e9f17 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php @@ -12,6 +12,7 @@ class Factory extends \JsonSchema\Constraints\Factory protected $constraintMap = [ 'schema' => Draft2019Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'unevaluatedProperties' => UnevaluatedPropertiesConstraint::class, 'additionalItems' => AdditionalItemsConstraint::class, 'dependentSchemas' => DependentSchemasConstraint::class, 'dependentRequired' => DependentRequiredConstraint::class, diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php new file mode 100644 index 00000000..0d7d40fd --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -0,0 +1,112 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!is_object($schema) || !property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { + return; + } + + if ($schema->unevaluatedProperties === true) { + return; + } + + $evaluated = $this->collectEvaluatedProperties($schema, $value); + $unevaluated = array_diff_key(get_object_vars($value), array_flip($evaluated)); + if (!$unevaluated) { + return; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($unevaluated as $propertyName => $propertyValue) { + $propertyPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + if (is_object($schema->unevaluatedProperties)) { + $propertyConstraint = $this->factory->createInstanceFor('schema'); + $propertyConstraint->check($propertyValue, $schema->unevaluatedProperties, $propertyPath, $i); + if ($propertyConstraint->isValid()) { + continue; + } + + $this->addErrors($propertyConstraint->getErrors()); + continue; + } + + $this->addError(ConstraintError::UNEVALUATED_PROPERTIES(), $propertyPath, ['found' => $propertyName]); + } + } + + /** + * @param object $schema + * @param object $value + * + * @return array + */ + private function collectEvaluatedProperties($schema, object $value): array + { + if (!is_object($schema)) { + return []; + } + + $evaluated = []; + + if (isset($schema->properties) && is_object($schema->properties)) { + $evaluated = array_merge($evaluated, array_keys(get_object_vars($schema->properties))); + } + + if (isset($schema->patternProperties) && is_object($schema->patternProperties)) { + foreach (get_object_vars($value) as $propertyName => $_) { + foreach (array_keys(get_object_vars($schema->patternProperties)) as $pattern) { + if (preg_match($this->createPregMatchPattern($pattern), (string) $propertyName)) { + $evaluated[] = $propertyName; + break; + } + } + } + } + + if (isset($schema->allOf) && is_array($schema->allOf)) { + foreach ($schema->allOf as $branch) { + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value)); + } + } + + return array_values(array_unique($evaluated)); + } + + private function createPregMatchPattern(string $pattern): string + { + $pattern = str_replace('\\p{digit}', '\\p{Nd}', $pattern); + $pattern = str_replace('\\p{Letter}', '\\p{L}', $pattern); + + return '/' . str_replace('/', '\\/', $pattern) . '/u'; + } +} diff --git a/tests/Constraints/UnevaluatedPropertiesTest.php b/tests/Constraints/UnevaluatedPropertiesTest.php new file mode 100644 index 00000000..d8929aeb --- /dev/null +++ b/tests/Constraints/UnevaluatedPropertiesTest.php @@ -0,0 +1,47 @@ +