From 94bbce4b2f149f47d10be072c6331688d6997dc8 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 20:12:15 +0300 Subject: [PATCH] Support remaining schema keywords (#157) --- CHANGELOG.md | 5 + README.md | 8 +- README.ru.md | 5 +- llms.txt | 3 +- src/Internal/Compilation/DocumentCompiler.php | 9 +- .../Reference/JsonPointerResolver.php | 2 +- src/Internal/Schema/SchemaCompiler.php | 73 +++++- src/Internal/Schema/SchemaValidator.php | 12 +- tests/SchemaValidatorTest.php | 222 +++++++++++++++++- 9 files changed, 311 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1188d11..fa28580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- **Added.** OpenAPI 3.1 schemas now support `prefixItems`, `contains` with + `minContains`/`maxContains`, `dependentRequired`, `dependentSchemas`, and + `unevaluatedProperties`/`unevaluatedItems` (#157). These keywords are + compiled, shape-checked, eagerly parsed, and included in directional + rewrites; `contentSchema` remains fail-closed. - **Added.** OpenAPI 3.1 schemas support the conditional keywords `if`, `then` and `else` (#157). Each is a subschema the compiler emits, the load -time check walks, and the backend evaluates under draft 2020-12, at any diff --git a/README.md b/README.md index b0ea6c0..99ea789 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Every Schema Object the validators will read — each parameter's, each request and response media type's, each response header's and multipart part header's — is compiled while the contract is built, in the direction it will be read in, so a schema this package cannot evaluate (an assertion -keyword outside the support matrix such as `prefixItems`, a `$schema` +keyword outside the support matrix such as `contentSchema`, a `$schema` naming another dialect, an OAS 3.0 `exclusiveMinimum` written as a number, a `pattern` the backend cannot parse, wherever it is nested) is `InvalidContract` out of the factory and never out of a `validate*()` call. @@ -488,8 +488,10 @@ carries a `readOnly` `id` is judged by `id`'s schema and a closed object (`additionalProperties: false`) still admits it, exactly as both specifications have it ("the required will take effect on the response only"). The rewrite recurses through `properties`, `patternProperties`, -`propertyNames`, `items`, `additionalProperties`, the conditional keywords -(`if`, `then`, `else`), the composition keywords and `$defs`, leaves `not` alone, and +`propertyNames`, `items`, `prefixItems`, `contains`, `dependentSchemas`, +`unevaluatedProperties`, `unevaluatedItems`, `additionalProperties`, the +conditional keywords (`if`, `then`, `else`), the composition keywords and +`$defs`, leaves `not` alone, and is what makes the same value and the same schema answer differently in the two directions. diff --git a/README.ru.md b/README.ru.md index cb97165..b33344d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -56,7 +56,7 @@ $contract = Contract::fromFile('openapi.yaml'); // нужен symfony/yaml каждого media type запроса и ответа, у каждого заголовка ответа и заголовка multipart-части, — компилируется при сборке контракта, в том направлении, в котором будет прочитан, поэтому схема, которую пакет не может вычислить -(assertion-ключевое слово вне матрицы поддержки вроде `prefixItems`, +(assertion-ключевое слово вне матрицы поддержки вроде `contentSchema`, `$schema` с другим диалектом, `exclusiveMinimum` числом под OAS 3.0, `pattern`, который бэкенд не разбирает, — на любой глубине), это `InvalidContract` из фабрики и никогда — из `validate*()`. @@ -489,7 +489,8 @@ $contract->accepts($value, $schema, SchemaDirection::Response); `id`, судится по схеме `id`, а закрытый объект (`additionalProperties: false`) по-прежнему его принимает, ровно как в обеих спецификациях («the required will take effect on the response only»). Перезапись рекурсивно проходит -`properties`, `patternProperties`, `propertyNames`, `items`, +`properties`, `patternProperties`, `propertyNames`, `items`, `prefixItems`, +`contains`, `dependentSchemas`, `unevaluatedProperties`, `unevaluatedItems`, `additionalProperties`, условные ключевые слова (`if`, `then`, `else`), композиционные ключевые слова и `$defs`, не трогает `not`, и именно из-за неё одно и то же значение и одна и та же diff --git a/llms.txt b/llms.txt index 9cd9139..ebae8ec 100644 --- a/llms.txt +++ b/llms.txt @@ -93,7 +93,8 @@ Rules: a response — the `required` entry is dropped, the subschema is kept, so the property stays declared and typed (a closed object still admits it, an open one still checks its type); recursive through `properties`, - `patternProperties`, `propertyNames`, `items`, `additionalProperties`, + `patternProperties`, `propertyNames`, `items`, `prefixItems`, `contains`, + `dependentSchemas`, `unevaluatedProperties`, `unevaluatedItems`, `additionalProperties`, `if`/`then`/`else`, `allOf`/`anyOf`/`oneOf` and `$defs`, never into `not`; `SchemaCheck::effective($schema, $direction)` returns exactly that rewrite; - declared `security` is NOT enforced: requirements compile and are checked diff --git a/src/Internal/Compilation/DocumentCompiler.php b/src/Internal/Compilation/DocumentCompiler.php index 9804c8a..2f6099f 100644 --- a/src/Internal/Compilation/DocumentCompiler.php +++ b/src/Internal/Compilation/DocumentCompiler.php @@ -45,13 +45,16 @@ private const array BODYLESS_METHODS_IN_30 = ['delete', 'get', 'head']; /** Keywords whose value is one subschema. @var list */ - private const array SINGLE_SUBSCHEMA_KEYWORDS = ['additionalProperties', 'items', 'not', 'if', 'then', 'else']; + private const array SINGLE_SUBSCHEMA_KEYWORDS = [ + 'additionalProperties', 'items', 'not', 'propertyNames', 'if', 'then', 'else', + 'contains', 'unevaluatedProperties', 'unevaluatedItems', + ]; /** Keywords whose value is a list of subschemas. @var list */ - private const array SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf']; + private const array SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; /** Keywords whose value is a map of subschemas. @var list */ - private const array SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'properties']; + private const array SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'dependentSchemas']; /** @param array $document */ public function compile(array $document, ?DocumentGraph $graph = null, int $resolvedNodes = Limits::DEFAULT_RESOLVED_NODES): CompiledDocument diff --git a/src/Internal/Reference/JsonPointerResolver.php b/src/Internal/Reference/JsonPointerResolver.php index 98ca37c..28d25b0 100644 --- a/src/Internal/Reference/JsonPointerResolver.php +++ b/src/Internal/Reference/JsonPointerResolver.php @@ -56,7 +56,7 @@ final class JsonPointerResolver * * @var list */ - private const array SCHEMA_DATA_KEYWORDS = ['const', 'default', 'enum', 'example', 'examples']; + private const array SCHEMA_DATA_KEYWORDS = ['const', 'default', 'enum', 'example', 'examples', 'dependentRequired']; /** * The same, outside a Schema Object: the `example` a Parameter, Header or diff --git a/src/Internal/Schema/SchemaCompiler.php b/src/Internal/Schema/SchemaCompiler.php index 7fc8a78..b57ef1c 100644 --- a/src/Internal/Schema/SchemaCompiler.php +++ b/src/Internal/Schema/SchemaCompiler.php @@ -43,6 +43,14 @@ final class SchemaCompiler 'if' => false, 'then' => false, 'else' => false, + // A tuple position, a membership test and a "nothing else evaluated" + // closure. `contains` is judged against a subschema rather than + // being one the value walks into, and `unevaluated*` against the + // annotations its siblings left — the two need no special handling + // here beyond being normalized like any other subschema. + 'contains' => false, + 'unevaluatedProperties' => false, + 'unevaluatedItems' => false, ]; /** @var list */ @@ -50,6 +58,9 @@ final class SchemaCompiler 'allOf', 'anyOf', 'oneOf', + // The tuple form: each position its own subschema. Boolean entries are + // refused under 3.0 by normalizeSchemaValue(), like `allOf` members. + 'prefixItems', ]; /** @@ -71,17 +82,13 @@ final class SchemaCompiler '$defs', 'properties', 'patternProperties', + // A member name's subschema, keyed the same way `properties` is. + 'dependentSchemas', ]; /** @var list */ private const array UNSUPPORTED_ASSERTION_KEYWORDS = [ - 'contains', 'contentSchema', - 'dependentRequired', - 'dependentSchemas', - 'prefixItems', - 'unevaluatedItems', - 'unevaluatedProperties', ]; /** @@ -113,7 +120,7 @@ public function compile(array $schema, SchemaDialect $dialect): \stdClass private function shareSchemaGraph(\stdClass $schema): array { $referenceFree = !property_exists($schema, '$ref'); - foreach (['additionalProperties', 'items', 'not', 'propertyNames'] as $keyword) { + foreach (array_keys(self::SINGLE_SCHEMA_KEYWORDS) as $keyword) { /** @var bool|\stdClass|null $member */ $member = $schema->{$keyword} ?? null; if ($member instanceof \stdClass) { @@ -121,7 +128,7 @@ private function shareSchemaGraph(\stdClass $schema): array $referenceFree = $referenceFree && $childIsReferenceFree; } } - foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) { + foreach (self::SCHEMA_LIST_KEYWORDS as $keyword) { /** @var mixed $members */ $members = $schema->{$keyword} ?? null; if (!is_array($members)) { @@ -138,7 +145,7 @@ private function shareSchemaGraph(\stdClass $schema): array } $schema->{$keyword} = $members; } - foreach (['properties', 'patternProperties', '$defs'] as $keyword) { + foreach (self::SCHEMA_MAP_KEYWORDS as $keyword) { /** @var mixed $members */ $members = $schema->{$keyword} ?? null; if (!$members instanceof \stdClass) { @@ -202,6 +209,7 @@ private function normalizeNode(array $schema, SchemaDialect $dialect): array SchemaDialect::OpenApi30 => $this->normalizeOpenApi30($schema), SchemaDialect::OpenApi31 => $this->normalizeOpenApi31($schema), }; + /** @var array $schema */ foreach (self::UNSUPPORTED_ASSERTION_KEYWORDS as $keyword) { if (array_key_exists($keyword, $schema)) { @@ -215,6 +223,11 @@ private function normalizeNode(array $schema, SchemaDialect $dialect): array } } + if (array_key_exists('dependentRequired', $schema)) { + $schema['dependentRequired'] = $this->normalizeDependentRequired($schema['dependentRequired']); + } + /** @var array $schema */ + foreach (self::SINGLE_SCHEMA_KEYWORDS as $keyword => $booleanAllowed) { if (array_key_exists($keyword, $schema)) { $normalized = $this->normalizeSchemaValue($schema[$keyword], $dialect, $keyword, $booleanAllowed); @@ -268,6 +281,47 @@ private function normalizeNode(array $schema, SchemaDialect $dialect): array return $schema; } + /** + * `dependentRequired` as a map of member names to the names each one's + * presence demands. Nothing here is a subschema, so nothing is compiled: + * the shape is checked where it is written and the map goes to the backend + * as written, with a numeric-string member name cast back to the string + * PHP turned it into — a legal name, not a malformed one. + * + */ + private function normalizeDependentRequired(mixed $value): \stdClass + { + if (!is_array($value)) { + throw UnsupportedSchema::atKeyword('dependentRequired', 'expected an object of member name to required member names'); + } + + /** @var array $value */ + $normalized = []; + /** @var mixed $required */ + foreach ($value as $name => $required) { + if (!is_array($required) || !array_is_list($required)) { + throw UnsupportedSchema::atKeyword('dependentRequired', sprintf('the members required by "%s" must be a list of names', (string) $name)); + } + /** @var mixed $member */ + foreach ($required as $member) { + if (!is_string($member)) { + throw UnsupportedSchema::atKeyword('dependentRequired', sprintf('a member required by "%s" must be a name', (string) $name)); + } + } + /** @var list $members */ + $members = $required; + if (count(array_unique($members)) !== count($members)) { + throw UnsupportedSchema::atKeyword('dependentRequired', sprintf('the members required by "%s" must be unique', (string) $name)); + } + $normalized[(string) $name] = $members; + } + + /** @var \stdClass $object */ + $object = (object) $normalized; + + return $object; + } + /** * @param array $schema * @return array @@ -291,6 +345,7 @@ private function normalizeOpenApi30(array $schema): array unset($schema['nullable']); } + /** @var array $schema */ $schema = $this->normalizeExclusiveBound($schema, 'minimum'); return $this->normalizeExclusiveBound($schema, 'maximum'); diff --git a/src/Internal/Schema/SchemaValidator.php b/src/Internal/Schema/SchemaValidator.php index ce488b6..22a1523 100644 --- a/src/Internal/Schema/SchemaValidator.php +++ b/src/Internal/Schema/SchemaValidator.php @@ -40,7 +40,7 @@ final class SchemaValidator * alone on purpose — what a `readOnly` property means under a negation * is not something either specification says. */ - private const array DIRECTIONAL_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'propertyNames', 'items', 'additionalProperties', 'if', 'then', 'else', 'allOf', 'anyOf', 'oneOf']; + private const array DIRECTIONAL_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'propertyNames', 'items', 'additionalProperties', 'if', 'then', 'else', 'allOf', 'anyOf', 'oneOf', 'prefixItems', 'contains', 'unevaluatedProperties', 'unevaluatedItems', 'dependentSchemas']; /** * {@see SUBSCHEMA_KEYWORDS}['single'] without `not`: the members the @@ -49,7 +49,7 @@ final class SchemaValidator * * @var list */ - private const array REWRITTEN_SINGLE_KEYWORDS = ['additionalProperties', 'items', 'propertyNames', 'if', 'then', 'else']; + private const array REWRITTEN_SINGLE_KEYWORDS = ['additionalProperties', 'items', 'propertyNames', 'if', 'then', 'else', 'contains', 'unevaluatedProperties', 'unevaluatedItems']; /** * Every keyword under which the compiler emits a subschema, by the shape @@ -59,9 +59,9 @@ final class SchemaValidator * @var array{single: list, list: list, map: list} */ private const array SUBSCHEMA_KEYWORDS = [ - 'single' => ['additionalProperties', 'items', 'not', 'propertyNames', 'if', 'then', 'else'], - 'list' => ['allOf', 'anyOf', 'oneOf'], - 'map' => ['$defs', 'properties', 'patternProperties'], + 'single' => ['additionalProperties', 'items', 'not', 'propertyNames', 'if', 'then', 'else', 'contains', 'unevaluatedProperties', 'unevaluatedItems'], + 'list' => ['allOf', 'anyOf', 'oneOf', 'prefixItems'], + 'map' => ['$defs', 'properties', 'patternProperties', 'dependentSchemas'], ]; /** @@ -599,7 +599,7 @@ public function effectiveSchema(array $schema, SchemaDirection $direction): arra $defs[$name] = $this->effectiveSchema($def, $direction); } $schema['$defs'] = $defs; - } elseif (($keyword === 'properties' || $keyword === 'patternProperties') && is_array($schema[$keyword])) { + } elseif (($keyword === 'properties' || $keyword === 'patternProperties' || $keyword === 'dependentSchemas') && is_array($schema[$keyword])) { $flag = $direction->foreignFlag(); $properties = []; /** @var array $foreign */ diff --git a/tests/SchemaValidatorTest.php b/tests/SchemaValidatorTest.php index 94404f3..6bae685 100644 --- a/tests/SchemaValidatorTest.php +++ b/tests/SchemaValidatorTest.php @@ -93,6 +93,85 @@ public function mixedReferenceGraphsShareOnlyTheSafeSubschemas(): void Assert::true($first->{'$defs'}->Value === $second->{'$defs'}->Value); } + public function sharingWalksEverySubschemaPositionAndKeepsReferencesLocal(): void + { + foreach (['if', 'then', 'else', 'contains', 'unevaluatedProperties', 'unevaluatedItems', 'prefixItems', 'dependentSchemas'] as $keyword) { + $compiler = new SchemaCompiler(); + $wrap = static fn(array $node): array => match ($keyword) { + 'prefixItems' => [$keyword => [$node]], + 'dependentSchemas' => [$keyword => ['trigger' => $node]], + default => [$keyword => $node], + }; + $shared = $wrap(['type' => 'string']); + Assert::true($compiler->compile($shared, SchemaDialect::OpenApi31) === $compiler->compile($shared, SchemaDialect::OpenApi31)); + $referenced = [...$wrap(['$ref' => '#/$defs/Value']), '$defs' => ['Value' => ['type' => 'string']]]; + Assert::false($compiler->compile($referenced, SchemaDialect::OpenApi31) === $compiler->compile($referenced, SchemaDialect::OpenApi31)); + } + } + + public function dependentRequiredPreservesNumericAndEmptyMemberMaps(): void + { + $validator = new SchemaValidator(); + $schema = ['type' => 'object', 'dependentRequired' => [0 => ['other']]]; + Assert::false($validator->isValid((object) ['0' => true], $schema, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['0' => true, 'other' => true], $schema, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) [], ['dependentRequired' => []], SchemaDialect::OpenApi31)); + } + + #[Property(runs: 150)] + public function directionalRewriteReachesEveryNewPositionAndIsAFixedPoint(string $keyword, bool $response): void + { + $direction = $response ? SchemaDirection::Response : SchemaDirection::Request; + $node = ['required' => ['id', 'secret'], 'properties' => [ + 'id' => ['type' => 'integer', 'readOnly' => true], + 'secret' => ['type' => 'string', 'writeOnly' => true], + ]]; + $wrap = static fn(array $member): array => match ($keyword) { + 'prefixItems' => [$keyword => [$member]], + 'dependentSchemas' => [$keyword => ['trigger' => $member]], + default => [$keyword => $member], + }; + $schema = $wrap($node); + $expected = $wrap([...$node, 'required' => [$response ? 'id' : 'secret']]); + $validator = new SchemaValidator(); + $effective = $validator->effectiveSchema($schema, $direction); + + Assert::same($effective, $expected); + Assert::same($validator->effectiveSchema($effective, $direction), $effective); + } + + public static function directionalRewriteReachesEveryNewPositionAndIsAFixedPointGenerators(): array + { + return ['keyword' => Gen::elements(['prefixItems', 'contains', 'dependentSchemas', 'unevaluatedProperties', 'unevaluatedItems']), 'response' => Gen::bool()]; + } + + #[Property(runs: 150)] + public function directionalRewriteKeepsEvaluatedAnnotations(bool $extra, bool $response, int $id): void + { + $direction = $response ? SchemaDirection::Response : SchemaDirection::Request; + $flag = $direction->foreignFlag(); + $node = ['type' => 'object', 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', $flag => true]]]; + $object = (object) ($extra ? ['id' => $id, 'extra' => true] : ['id' => $id]); + $validator = new SchemaValidator(); + $schemasAndValues = [ + [['allOf' => [$node], 'unevaluatedProperties' => false], $object, !$extra], + [['anyOf' => [$node, ['type' => 'null']], 'unevaluatedProperties' => false], $object, !$extra], + [['type' => 'array', 'prefixItems' => [$node], 'unevaluatedItems' => false], [$object], true], + [['type' => 'array', 'contains' => $node, 'unevaluatedItems' => false], [$object], true], + [['type' => 'object', 'properties' => ['trigger' => ['type' => 'boolean']], 'dependentSchemas' => ['trigger' => $node], 'unevaluatedProperties' => false], (object) ['trigger' => true, ...get_object_vars($object)], !$extra], + ]; + foreach ($schemasAndValues as [$schema, $value, $expected]) { + $effective = $validator->effectiveSchema($schema, $direction); + Assert::same($validator->isValid($value, $schema, SchemaDialect::OpenApi31, $direction), $expected); + Assert::same($validator->isValid($value, $effective, SchemaDialect::OpenApi31, $direction), $expected); + } + } + + public static function directionalRewriteKeepsEvaluatedAnnotationsGenerators(): array + { + return ['extra' => Gen::bool(), 'response' => Gen::bool(), 'id' => Gen::int()]; + } + /** @return iterable, mixed, mixed}> */ public static function dialectProvider(): iterable @@ -237,12 +316,12 @@ public function rejectsUnsupportedAssertionKeyword(): void { try { (new SchemaValidator())->isValid( - value: ['a'], - schema: ['type' => 'array', 'prefixItems' => [['type' => 'string']]], + value: '{"a":1}', + schema: ['type' => 'string', 'contentMediaType' => 'application/json', 'contentSchema' => ['type' => 'object']], dialect: SchemaDialect::OpenApi31, ); } catch (UnsupportedSchema $exception) { - Assert::string($exception->getMessage())->contains('prefixItems'); + Assert::string($exception->getMessage())->contains('contentSchema'); return; } @@ -250,6 +329,28 @@ public function rejectsUnsupportedAssertionKeyword(): void Assert::true(actual: false, message: 'Expected unsupported schema exception'); } + public function refusesMalformedDependentRequiredAndThirtyBooleanTupleItems(): void + { + foreach ([ + ['dependentRequired' => ['card' => 'address']], + ['dependentRequired' => ['card' => ['address', 1]]], + ] as $malformed) { + try { + (new SchemaValidator())->isValid((object) [], $malformed, SchemaDialect::OpenApi31); + Assert::true(actual: false, message: 'Expected malformed dependentRequired to be refused'); + } catch (UnsupportedSchema $exception) { + Assert::string($exception->getMessage())->contains('dependentRequired'); + } + } + + try { + (new SchemaValidator())->isValid(['x'], ['prefixItems' => [true]], SchemaDialect::OpenApi30); + Assert::true(actual: false, message: 'Expected a boolean tuple item to be refused under OAS 3.0'); + } catch (UnsupportedSchema $exception) { + Assert::string($exception->getMessage())->contains('boolean schemas require OAS 3.1'); + } + } + public function filtersDirectionalPropertiesInsideNestedStructures(): void { $validator = new SchemaValidator(); @@ -327,6 +428,116 @@ public function evaluatesConditionalSchemas(): void Assert::false($validator->isValid((object) ['kind' => 'dog', 'sound' => 'meow'], $schema, SchemaDialect::OpenApi31)); } + public function evaluatesTupleAndContainsSchemasIncludingBounds(): void + { + $validator = new SchemaValidator(); + $tuple = [ + 'type' => 'array', + 'prefixItems' => [ + ['type' => 'string', 'const' => 'v1'], + ['type' => 'integer', 'minimum' => 1], + ], + 'items' => false, + ]; + $contains = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 2, + 'maxContains' => 3, + ]; + + Assert::true($validator->isValid(['v1', 2], $tuple, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid(['v1', 0], $tuple, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid(['v1', 2, 3], $tuple, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid([5, 6, 1], $contains, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid([5, 1], $contains, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid([5, 6, 7, 8], $contains, SchemaDialect::OpenApi31)); + } + + public function evaluatesDependentSchemasAndRequiredMembers(): void + { + $validator = new SchemaValidator(); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'creditCard' => ['type' => 'string'], + 'billingAddress' => ['type' => 'string'], + 'shippingAddress' => ['type' => 'string'], + 'kind' => ['type' => 'string'], + 'taxId' => ['type' => 'string'], + ], + 'dependentRequired' => ['creditCard' => ['billingAddress']], + 'dependentSchemas' => [ + 'shippingAddress' => ['required' => ['kind']], + 'kind' => ['if' => ['properties' => ['kind' => ['const' => 'business']]], 'then' => ['required' => ['taxId']]], + ], + ]; + + Assert::true($validator->isValid((object) [], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['creditCard' => 'x'], $schema, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['creditCard' => 'x', 'billingAddress' => 'a'], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['shippingAddress' => 'a'], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['kind' => 'business'], $schema, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['kind' => 'business', 'taxId' => 'x'], $schema, SchemaDialect::OpenApi31)); + } + + public function evaluatesUnevaluatedClosuresAcrossApplicators(): void + { + $validator = new SchemaValidator(); + $properties = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readOnly' => true], + 'name' => ['type' => 'string'], + ], + 'unevaluatedProperties' => false, + ]; + $items = [ + 'type' => 'array', + 'prefixItems' => [['type' => 'string']], + 'items' => ['type' => 'integer'], + 'unevaluatedItems' => false, + ]; + + Assert::true($validator->isValid((object) ['id' => 1, 'name' => 'x'], $properties, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['id' => 1, 'name' => 'x', 'extra' => true], $properties, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid(['v1', 1, 2], $items, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid(['v1', 1, 'extra'], $items, SchemaDialect::OpenApi31)); + } + + public function directionalRewriteReachesTupleAndDependentSubschemas(): void + { + $validator = new SchemaValidator(); + $tuple = [ + 'type' => 'array', + 'prefixItems' => [[ + 'type' => 'object', + 'required' => ['id', 'name'], + 'properties' => [ + 'id' => ['type' => 'integer', 'readOnly' => true], + 'name' => ['type' => 'string'], + ], + ]], + ]; + $dependent = [ + 'type' => 'object', + 'properties' => ['trigger' => ['type' => 'boolean']], + 'dependentSchemas' => ['trigger' => [ + 'required' => ['id', 'name'], + 'properties' => [ + 'id' => ['type' => 'integer', 'readOnly' => true], + 'name' => ['type' => 'string'], + ], + ]], + ]; + + Assert::true($validator->isValid([(object) ['name' => 'x']], $tuple, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid([(object) ['id' => 1, 'name' => 'x']], $tuple, SchemaDialect::OpenApi31, SchemaDirection::Response)); + Assert::true($validator->isValid((object) ['trigger' => true, 'name' => 'x'], $dependent, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['trigger' => true, 'id' => 1, 'name' => 'x'], $dependent, SchemaDialect::OpenApi31, SchemaDirection::Response)); + } + public function directionalRewriteReachesConditionalSchemas(): void { $validator = new SchemaValidator(); @@ -488,6 +699,11 @@ public static function unparsableNodeProvider(): iterable yield 'under items' => [['type' => 'array', 'items' => $bad], 'pattern value must be a valid regex']; yield 'under additionalProperties' => [['type' => 'object', 'additionalProperties' => $bad], 'pattern value must be a valid regex']; yield 'under not' => [['not' => $bad], 'pattern value must be a valid regex']; + foreach (['contains', 'unevaluatedProperties', 'unevaluatedItems'] as $keyword) { + yield $keyword => [[$keyword => $bad], 'pattern value must be a valid regex']; + } + yield 'prefixItems' => [['prefixItems' => [$bad]], 'pattern value must be a valid regex']; + yield 'dependentSchemas' => [['dependentSchemas' => ['trigger' => $bad]], 'pattern value must be a valid regex']; yield 'under oneOf, with no allOf before it' => [['oneOf' => [['type' => 'integer'], $bad]], 'pattern value must be a valid regex']; yield 'under anyOf' => [['anyOf' => [$bad]], 'pattern value must be a valid regex']; yield 'under properties, with no $defs before it' => [['type' => 'object', 'properties' => ['ok' => ['type' => 'string'], 'a' => $bad]], 'pattern value must be a valid regex'];