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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- **Added.** OpenAPI 3.1 schemas now support `patternProperties` and
`propertyNames`. Their subschemas are compiled and checked at contract load,
and direction-specific `readOnly`/`writeOnly` handling reaches nested
`patternProperties` schemas.

## 0.15.0 — 2026-09-20

- **Added.** `Contract::validateWebhook(string $name, RequestInterface
Expand Down
2 changes: 1 addition & 1 deletion 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 `patternProperties`, a `$schema`
keyword outside the support matrix such as `prefixItems`, 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
2 changes: 1 addition & 1 deletion 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-ключевое слово вне матрицы поддержки вроде `patternProperties`,
(assertion-ключевое слово вне матрицы поддержки вроде `prefixItems`,
`$schema` с другим диалектом, `exclusiveMinimum` числом под OAS 3.0,
`pattern`, который бэкенд не разбирает, — на любой глубине), это
`InvalidContract` из фабрики и никогда — из `validate*()`.
Expand Down
8 changes: 4 additions & 4 deletions src/Internal/Schema/SchemaCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ final class SchemaCompiler
'additionalProperties' => true,
'items' => false,
'not' => false,
'propertyNames' => false,
];

/** @var list<string> */
Expand Down Expand Up @@ -66,6 +67,7 @@ final class SchemaCompiler
private const array SCHEMA_MAP_KEYWORDS = [
'$defs',
'properties',
'patternProperties',
];

/** @var list<string> */
Expand All @@ -76,9 +78,7 @@ final class SchemaCompiler
'dependentSchemas',
'else',
'if',
'patternProperties',
'prefixItems',
'propertyNames',
'then',
'unevaluatedItems',
'unevaluatedProperties',
Expand Down Expand Up @@ -113,7 +113,7 @@ public function compile(array $schema, SchemaDialect $dialect): \stdClass
private function shareSchemaGraph(\stdClass $schema): array
{
$referenceFree = !property_exists($schema, '$ref');
foreach (['additionalProperties', 'items', 'not'] as $keyword) {
foreach (['additionalProperties', 'items', 'not', 'propertyNames'] as $keyword) {
/** @var bool|\stdClass|null $member */
$member = $schema->{$keyword} ?? null;
if ($member instanceof \stdClass) {
Expand All @@ -138,7 +138,7 @@ private function shareSchemaGraph(\stdClass $schema): array
}
$schema->{$keyword} = $members;
}
foreach (['properties', '$defs'] as $keyword) {
foreach (['properties', 'patternProperties', '$defs'] as $keyword) {
/** @var mixed $members */
$members = $schema->{$keyword} ?? null;
if (!$members instanceof \stdClass) {
Expand Down
24 changes: 13 additions & 11 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', 'items', 'additionalProperties', 'allOf', 'anyOf', 'oneOf'];
private const array DIRECTIONAL_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'propertyNames', 'items', 'additionalProperties', 'allOf', 'anyOf', 'oneOf'];

/**
* Every keyword under which the compiler emits a subschema, by the shape
Expand All @@ -50,9 +50,9 @@ final class SchemaValidator
* @var array{single: list<string>, list: list<string>, map: list<string>}
*/
private const array SUBSCHEMA_KEYWORDS = [
'single' => ['additionalProperties', 'items', 'not'],
'single' => ['additionalProperties', 'items', 'not', 'propertyNames'],
'list' => ['allOf', 'anyOf', 'oneOf'],
'map' => ['$defs', 'properties'],
'map' => ['$defs', 'properties', 'patternProperties'],
];

/**
Expand Down Expand Up @@ -590,7 +590,7 @@ public function effectiveSchema(array $schema, SchemaDirection $direction): arra
$defs[$name] = $this->effectiveSchema($def, $direction);
}
$schema['$defs'] = $defs;
} elseif ($keyword === 'properties' && is_array($schema[$keyword])) {
} elseif (($keyword === 'properties' || $keyword === 'patternProperties') && is_array($schema[$keyword])) {
$flag = $direction->foreignFlag();
$properties = [];
/** @var array<string, true> $foreign */
Expand All @@ -606,18 +606,20 @@ public function effectiveSchema(array $schema, SchemaDirection $direction): arra
continue;
}
/** @var array<string, mixed> $property */
if (($property[$flag] ?? false) === true) {
if ($keyword === 'properties' && ($property[$flag] ?? false) === true) {
$foreign[(string) $name] = true;
}
$properties[$name] = $this->effectiveSchema($property, $direction);
}
$schema['properties'] = $properties;
/** @var mixed $required */
$required = $schema['required'] ?? null;
if (is_array($required)) {
$schema['required'] = array_values(array_filter($required, static fn(mixed $name): bool => !is_string($name) || !isset($foreign[$name])));
$schema[$keyword] = $properties;
if ($keyword === 'properties') {
/** @var mixed $required */
$required = $schema['required'] ?? null;
if (is_array($required)) {
$schema['required'] = array_values(array_filter($required, static fn(mixed $name): bool => !is_string($name) || !isset($foreign[$name])));
}
}
} elseif (($keyword === 'items' || $keyword === 'additionalProperties') && is_array($schema[$keyword]) && !array_is_list($schema[$keyword])) {
} elseif (($keyword === 'items' || $keyword === 'additionalProperties' || $keyword === 'propertyNames') && is_array($schema[$keyword]) && !array_is_list($schema[$keyword])) {
/** @var array<string, mixed> $items */
$items = $schema[$keyword];
$schema[$keyword] = $this->effectiveSchema($items, $direction);
Expand Down
5 changes: 3 additions & 2 deletions src/SchemaCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ public static function isMultipleOf(int|float $value, int|float $divisor): bool
*
* A property the other direction owns (`readOnly` on a request,
* `writeOnly` on a response) loses its `required` entry and keeps its
* subschema; the rewrite recurses through `properties`, `items`,
* `additionalProperties`, `allOf`, `anyOf` and `oneOf`, leaves `not`
* subschema; the rewrite recurses through `properties`,
* `patternProperties`, `propertyNames`, `items`, `additionalProperties`,
* `allOf`, `anyOf` and `oneOf`, leaves `not`
* alone, and is idempotent. Members it does not read are passed through
* as written. The dialect plays no part: the rewrite is the same under
* OAS 3.0 and 3.1.
Expand Down
22 changes: 20 additions & 2 deletions tests/ContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ public function matchesConcretePathBeforeTemplate(): void
Assert::same($matched->pathParameters, []);
}

public function validatesPatternPropertiesAndPropertyNamesInContracts(): void
{
$contract = Contract::fromArray([
'openapi' => '3.1.0',
'paths' => ['/labels' => ['get' => ['responses' => [
'200' => ['content' => ['application/json' => ['schema' => [
'type' => 'object',
'patternProperties' => ['^x-' => ['type' => 'integer', 'minimum' => 1]],
'propertyNames' => ['pattern' => '^x-[a-z]+$'],
'additionalProperties' => false,
]]]],
]]]],
]);
$response = static fn(string $body): Response => new Response(200, ['Content-Type' => 'application/json'], $body);

Assert::true($contract->validateResponse('GET /labels', $response('{"x-count":2}'))->isValid());
Assert::false($contract->validateResponse('GET /labels', $response('{"x-count":0}'))->isValid());
Assert::false($contract->validateResponse('GET /labels', $response('{"count":2}'))->isValid());
}

public function decodesPathParameterExactlyOnce(): void
{
$contract = Contract::fromArray([
Expand Down Expand Up @@ -2001,7 +2021,6 @@ public static function unsupportedSchemaPositionProvider(): iterable
$response = static fn(array $schema): array => $operation(['responses' => ['200' => ['content' => ['application/json' => ['schema' => $schema]]]]]);
$unsupported = 'assertion is outside the v0.1 support matrix';

yield 'patternProperties on a parameter' => [$parameter(['type' => 'object', 'patternProperties' => ['^x' => ['type' => 'string']]]), $unsupported];
yield 'a draft-07 $schema on a request body' => [
['openapi' => '3.1.0', 'paths' => ['/b' => ['post' => [
'requestBody' => ['content' => ['application/json' => ['schema' => ['$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object']]]],
Expand All @@ -2014,7 +2033,6 @@ public static function unsupportedSchemaPositionProvider(): iterable
['openapi' => '3.0.3', 'paths' => ['/q' => ['get' => ['responses' => ['200' => ['headers' => ['X-N' => ['schema' => ['type' => 'integer', 'exclusiveMinimum' => 5]]]]]]]]],
'OAS 3.0 requires a boolean',
];
yield 'propertyNames on a response body' => [$response(['type' => 'object', 'propertyNames' => ['pattern' => '^x']]), $unsupported];
yield 'a minimum that is not a number, under items of a parameter' => [$parameter(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => '5']]), 'minimum must contain a valid number'];
yield 'an if/then on a multipart part header' => [
['openapi' => '3.1.0', 'paths' => ['/b' => ['post' => [
Expand Down
8 changes: 8 additions & 0 deletions tests/SchemaCheckTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ public static function directionalSchemaProvider(): iterable
yield 'closed object' => [[...$item, 'additionalProperties' => false], (object) ['id' => 1, 'name' => 'a']];
yield 'list of items' => [['type' => 'array', 'items' => $item], [(object) ['name' => 'a']]];
yield 'map of items' => [['type' => 'object', 'additionalProperties' => $item], (object) ['k' => (object) ['name' => 'a']]];
yield 'pattern properties' => [[
'type' => 'object',
'patternProperties' => ['^x-' => $item],
], (object) ['x-item' => (object) ['name' => 'a']]];
yield 'property names' => [[
'type' => 'object',
'propertyNames' => ['pattern' => '^x-'],
], (object) ['x-item' => 'a']];
yield 'composition' => [['allOf' => [$item, ['type' => 'object']]], (object) ['name' => 'a']];
yield 'negation is left alone' => [['not' => $item], (object) ['name' => 'a']];
yield 'boolean member passes through' => [['type' => 'object', 'properties' => ['open' => true], 'required' => ['open']], (object) []];
Expand Down
41 changes: 41 additions & 0 deletions tests/SchemaValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,47 @@ public function filtersDirectionalPropertiesInsideNestedStructures(): void
Assert::false($validator->isValid((object) ['k' => $value], ['type' => 'object', 'additionalProperties' => $object], SchemaDialect::OpenApi31, SchemaDirection::Response));
}

public function evaluatesPatternPropertiesAndPropertyNames(): void
{
$validator = new SchemaValidator();
$patternProperties = [
'type' => 'object',
'patternProperties' => ['^x-' => ['type' => 'integer']],
'additionalProperties' => false,
];
$propertyNames = [
'type' => 'object',
'propertyNames' => ['pattern' => '^x-[a-z]+$'],
];

Assert::true($validator->isValid((object) ['x-count' => 3], $patternProperties, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['x-count' => '3'], $patternProperties, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['count' => 3], $patternProperties, SchemaDialect::OpenApi31));
Assert::true($validator->isValid((object) ['x-count' => 3], $propertyNames, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['count' => 3], $propertyNames, SchemaDialect::OpenApi31));
}

public function patternSchemasApplyTogetherAndPropertyNamesChecksDeclaredKeys(): void
{
$validator = new SchemaValidator();
$schema = [
'type' => 'object',
'properties' => ['x-id' => ['type' => 'integer']],
'patternProperties' => [
'^x-' => ['minimum' => 1],
'id$' => ['maximum' => 5],
],
'propertyNames' => ['pattern' => '^x-'],
'additionalProperties' => true,
];

Assert::true($validator->isValid((object) ['x-id' => 3], $schema, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['x-id' => 6], $schema, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['x-id' => 0], $schema, SchemaDialect::OpenApi31));
Assert::true($validator->isValid((object) ['x-other' => 1], $schema, SchemaDialect::OpenApi31));
Assert::false($validator->isValid((object) ['count' => 1], $schema, SchemaDialect::OpenApi31));
}

/**
* The guards `SchemaCheck` relies on live here, and mutants in this class
* are matched to this test class alone — see the package AGENTS.md on
Expand Down
Loading
Loading