Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*()`.
Expand Down Expand Up @@ -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`, и именно из-за неё
одно и то же значение и одна и та же
Expand Down
3 changes: 2 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/Internal/Compilation/DocumentCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@
private const array BODYLESS_METHODS_IN_30 = ['delete', 'get', 'head'];

/** Keywords whose value is one subschema. @var list<string> */
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<string> */
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<string> */
private const array SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'properties'];
private const array SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'dependentSchemas'];

/** @param array<string, mixed> $document */
public function compile(array $document, ?DocumentGraph $graph = null, int $resolvedNodes = Limits::DEFAULT_RESOLVED_NODES): CompiledDocument
Expand Down
2 changes: 1 addition & 1 deletion src/Internal/Reference/JsonPointerResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ final class JsonPointerResolver
*
* @var list<string>
*/
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
Expand Down
73 changes: 64 additions & 9 deletions src/Internal/Schema/SchemaCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,24 @@ 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<string> */
private const array SCHEMA_LIST_KEYWORDS = [
'allOf',
'anyOf',
'oneOf',
// The tuple form: each position its own subschema. Boolean entries are
// refused under 3.0 by normalizeSchemaValue(), like `allOf` members.
'prefixItems',
];

/**
Expand All @@ -71,17 +82,13 @@ final class SchemaCompiler
'$defs',
'properties',
'patternProperties',
// A member name's subschema, keyed the same way `properties` is.
'dependentSchemas',
];

/** @var list<string> */
private const array UNSUPPORTED_ASSERTION_KEYWORDS = [
'contains',
'contentSchema',
'dependentRequired',
'dependentSchemas',
'prefixItems',
'unevaluatedItems',
'unevaluatedProperties',
];

/**
Expand Down Expand Up @@ -113,15 +120,15 @@ 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) {
[$schema->{$keyword}, $childIsReferenceFree] = $this->shareSchemaNode($member);
$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)) {
Expand All @@ -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) {
Expand Down Expand Up @@ -202,6 +209,7 @@ private function normalizeNode(array $schema, SchemaDialect $dialect): array
SchemaDialect::OpenApi30 => $this->normalizeOpenApi30($schema),
SchemaDialect::OpenApi31 => $this->normalizeOpenApi31($schema),
};
/** @var array<string, mixed> $schema */

foreach (self::UNSUPPORTED_ASSERTION_KEYWORDS as $keyword) {
if (array_key_exists($keyword, $schema)) {
Expand All @@ -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<string, mixed> $schema */

foreach (self::SINGLE_SCHEMA_KEYWORDS as $keyword => $booleanAllowed) {
if (array_key_exists($keyword, $schema)) {
$normalized = $this->normalizeSchemaValue($schema[$keyword], $dialect, $keyword, $booleanAllowed);
Expand Down Expand Up @@ -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<array-key, mixed> $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<string> $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<string, mixed> $schema
* @return array<string, mixed>
Expand All @@ -291,6 +345,7 @@ private function normalizeOpenApi30(array $schema): array
unset($schema['nullable']);
}

/** @var array<string, mixed> $schema */
$schema = $this->normalizeExclusiveBound($schema, 'minimum');

return $this->normalizeExclusiveBound($schema, 'maximum');
Expand Down
12 changes: 6 additions & 6 deletions src/Internal/Schema/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,7 +49,7 @@ final class SchemaValidator
*
* @var list<string>
*/
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
Expand All @@ -59,9 +59,9 @@ final class SchemaValidator
* @var array{single: list<string>, list: list<string>, map: list<string>}
*/
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'],
];

/**
Expand Down Expand Up @@ -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<string, true> $foreign */
Expand Down
Loading
Loading