Skip to content
2 changes: 2 additions & 0 deletions src/JsonSchema/ConstraintError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,21 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n
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
$propertyPath = ($path ?? new JsonPointer(''))->withPropertyPaths(
array_merge(($path ?? new JsonPointer(''))->getPropertyPaths(), [$key])
);
$schemaConstraint->check($additionalPropertiesValue, $schema->additionalProperties, $propertyPath, $i);
if ($schemaConstraint->isValid()) {
unset($additionalProperties[$key]);
}
}
}

foreach ($additionalProperties as $key => $additionalPropertiesValue) {
$this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $key]);
$propertyPath = ($path ?? new JsonPointer(''))->withPropertyPaths(
array_merge(($path ?? new JsonPointer(''))->getPropertyPaths(), [$key])
);
$this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $propertyPath, ['found' => $key]);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php

declare(strict_types=1);

namespace JsonSchema\Constraints\Drafts\Draft2019;

use JsonSchema\ConstraintError;
use JsonSchema\Constraints\ConstraintInterface;
use JsonSchema\Entity\ErrorBagProxy;
use JsonSchema\Entity\JsonPointer;

class UnevaluatedPropertiesConstraint implements ConstraintInterface
{
use ErrorBagProxy;

/** @var Factory */
private $factory;

public function __construct(?Factory $factory = null)
{
$this->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, $path);
$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
* @param array<int, string> $visitedRefs
*
* @return array<int, string>
*/
private function collectEvaluatedProperties($schema, object $value, ?JsonPointer $path = null, array $visitedRefs = []): array
{
if (!is_object($schema)) {
return [];
}

$evaluated = [];
if (property_exists($schema, '$ref') && is_string($schema->{'$ref'})) {
$reference = $schema->{'$ref'};
if (in_array($reference, $visitedRefs, true)) {
return [];
}

try {
$visitedRefs[] = $reference;
$resolvedSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema);
if (is_object($resolvedSchema)) {
$evaluated = array_merge(
$evaluated,
$this->collectEvaluatedProperties($resolvedSchema, $value, $path, $visitedRefs)
);
}
} catch (\Exception $e) {
// Let the normal reference validation report resolution errors.
}
}

$properties = get_object_vars($value);

if (property_exists($schema, 'unevaluatedProperties') && $schema->unevaluatedProperties === true) {
$evaluated = array_merge($evaluated, array_keys($properties));
}

if (isset($schema->properties) && is_object($schema->properties)) {
$evaluated = array_merge(
$evaluated,
array_intersect(array_keys(get_object_vars($schema->properties)), array_keys($properties))
);
}

if (isset($schema->patternProperties) && is_object($schema->patternProperties)) {
foreach ($properties as $propertyName => $_) {
foreach (array_keys(get_object_vars($schema->patternProperties)) as $pattern) {
if (preg_match($this->createPregMatchPattern($pattern), (string) $propertyName)) {
$evaluated[] = $propertyName;
break;
}
}
}
}

if (property_exists($schema, 'additionalProperties')) {
if ($schema->additionalProperties === true) {
$evaluated = array_merge($evaluated, array_keys($properties));
} elseif (is_object($schema->additionalProperties)) {
foreach (array_diff(array_keys($properties), $evaluated) as $propertyName) {
$propertyPath = $this->propertyPath($path, $propertyName);
if ($this->schemaIsValid($schema->additionalProperties, $properties[$propertyName], $propertyPath)) {
$evaluated[] = $propertyName;
}
}
}
}

if (isset($schema->allOf) && is_array($schema->allOf)) {
foreach ($schema->allOf as $branch) {
if (!$this->schemaIsValid($branch, $value, $path)) {
continue;
}

$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value, $path, $visitedRefs));
}
}

if (isset($schema->anyOf) && is_array($schema->anyOf)) {
foreach ($schema->anyOf as $branch) {
if (!$this->schemaIsValid($branch, $value, $path)) {
continue;
}

$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value, $path, $visitedRefs));
}
}

if (isset($schema->oneOf) && is_array($schema->oneOf)) {
$validBranches = [];
foreach ($schema->oneOf as $branch) {
if ($this->schemaIsValid($branch, $value, $path)) {
$validBranches[] = $branch;
}
}

if (count($validBranches) === 1) {
$evaluated = array_merge(
$evaluated,
$this->collectEvaluatedProperties($validBranches[0], $value, $path, $visitedRefs)
);
}
}

if (property_exists($schema, 'if')) {
$ifMatches = $this->schemaIsValid($schema->if, $value, $path);
if ($ifMatches) {
$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->if, $value, $path, $visitedRefs));
if (property_exists($schema, 'then') && $this->schemaIsValid($schema->then, $value, $path)) {
$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->then, $value, $path, $visitedRefs));
}
} elseif (property_exists($schema, 'else') && $this->schemaIsValid($schema->else, $value, $path)) {
$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($schema->else, $value, $path, $visitedRefs));
}
}

if (isset($schema->dependentSchemas) && is_object($schema->dependentSchemas)) {
foreach (get_object_vars($schema->dependentSchemas) as $propertyName => $dependentSchema) {
if (!array_key_exists($propertyName, $properties) || !$this->schemaIsValid($dependentSchema, $value, $path)) {
continue;
}

$evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($dependentSchema, $value, $path, $visitedRefs));
}
}

return array_values(array_unique($evaluated));
}

/**
* @param mixed $schema
* @param mixed $value
*/
private function schemaIsValid($schema, $value, ?JsonPointer $path = null): bool
{
$schemaConstraint = $this->factory->createInstanceFor('schema');
$schemaConstraint->check($value, $schema, $path);

return $schemaConstraint->isValid();
}

private function propertyPath(?JsonPointer $path, string $propertyName): JsonPointer
{
$basePath = $path ?? new JsonPointer('');

return $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName]));
}

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';
}
}
123 changes: 123 additions & 0 deletions tests/Constraints/Drafts/Draft2019/UnevaluatedPropertiesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace JsonSchema\Tests\Constraints\Drafts\Draft2019;

use JsonSchema\Constraints\Constraint;
use JsonSchema\DraftIdentifiers;
use JsonSchema\Tests\Constraints\BaseTestCase;

class UnevaluatedPropertiesTest extends BaseTestCase
{
protected $schemaSpec = DraftIdentifiers::DRAFT_2019_09;

public function getInvalidTests(): \Generator
{
yield [
'{"hello":"world","world":"hello","unexpected":true}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"allOf":[
{"properties":{"hello":{"type":"string"}},"required":["hello"]},
{"properties":{"world":{"type":"string"}},"required":["world"]}
]
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"foo":"foo","bar":"bar","baz":"not-baz"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"anyOf":[
{"properties":{"foo":{"const":"foo"}}},
{"properties":{"bar":{"const":"bar"}}}
]
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"kind":"a","value":"ok","extra":true}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"if":{"properties":{"kind":{"const":"a"}}},
"then":{"properties":{"value":{"type":"string"}}}
}',
Constraint::CHECK_MODE_STRICT,
];
}

public function getValidTests(): \Generator
{
yield [
'{"hello":"world","world":"hello"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"allOf":[
{"properties":{"hello":{"type":"string"}},"required":["hello"]},
{"properties":{"world":{"type":"string"}},"required":["world"]}
]
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"foo":"ok"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"additionalProperties":{"type":"string"}
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"foo":"foo","bar":"bar"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"anyOf":[
{"properties":{"foo":{"const":"foo"}}},
{"properties":{"bar":{"const":"bar"}}}
]
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"kind":"a","value":"ok"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"unevaluatedProperties":false,
"if":{"properties":{"kind":{"const":"a"}}},
"then":{"properties":{"value":{"type":"string"}}}
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'{"foo":"foo","bar":"bar"}',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"object",
"properties":{"foo":{"type":"string"}},
"unevaluatedProperties":false,
"dependentSchemas":{"foo":{"properties":{"bar":{"type":"string"}}}}
}',
Constraint::CHECK_MODE_STRICT,
];
}
}
Loading