diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cc9da..ce88d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## 0.16.0 — 2026-09-20 + +- **Added.** A schema that refers to itself — a tree whose `children` are + trees — is generated. `rasuvaeff/openapi-contract` 0.13 compiles such a + schema to `$defs` plus a local `{$ref: '#/$defs/'}` for every + reference back into the cycle; the generator unfolds the def three levels + below the root and ends at a leaf where the recursive member is left out — + an optional property omitted, an array that may be empty left empty, a + branch of `anyOf`/`oneOf` skipped, a member of an `allOf` inlined as the + def's body. A def whose only way down is a required member that is the def + itself has no finite instance and is refused (`recursive schema "…" has no + finite instance`), as is a def that is an `allOf` member of itself; a + `$ref` to anything but the schema's own `$defs` is refused by name. The + JSON body encoder follows the same references, so a nested empty object + three levels down is still written as `{}`. The zoo gains `tree.create`; + the recorded corpus grows by its 25 cases. +- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.13`. + ## 0.15.2 — 2026-09-19 - **Fixed.** Whether the `number` branch of a `oneOf` admits an integer was diff --git a/README.md b/README.md index c0e0739..1ac9a93 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ before it reaches a transport. - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.12.1 and `rasuvaeff/property-testing-core` ^0.10–^0.11 +- `rasuvaeff/openapi-contract` ^0.13 and `rasuvaeff/property-testing-core` ^0.10–^0.11 - `psr/http-message`, `psr/http-factory` and `psr/http-server-handler` implementations — a PSR-17 factory materializes requests, and `ContractSuite` drives a PSR-15 handler in process @@ -137,7 +137,8 @@ the same way. | `readOnly` (requests), `writeOnly` (responses) | dropped per direction | | `anyOf`, `oneOf` (provably disjoint branches, or one `integer` beside one `number` branch: a value is kept only when exactly one admits it, the number branch's `multipleOf` judged by the contract's own `SchemaCheck::isMultipleOf()`), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported | | `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed | -| `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | +| `$defs` + local `$ref` (`#/$defs/…`) — the contract's compiled form of a schema that refers to itself, a tree whose `children` are trees | supported; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out (an optional property omitted, an array that may be empty left empty, a branch of a choice skipped); a def whose only way down is a required member that is the def itself has no finite instance and fails closed; a `$ref` to anything but the schema's own `$defs` fails closed | +| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | Every unsatisfiable combination the compiler can recognise is refused at compile time; what remains probabilistic (a `pattern` that rarely matches its diff --git a/README.ru.md b/README.ru.md index 018746c..a84c4d0 100644 --- a/README.ru.md +++ b/README.ru.md @@ -24,7 +24,7 @@ styles и JSON, form-urlencoded или multipart request body, после чег - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.12.1 и `rasuvaeff/property-testing-core` ^0.10–^0.11 +- `rasuvaeff/openapi-contract` ^0.13 и `rasuvaeff/property-testing-core` ^0.10–^0.11 - реализации `psr/http-message`, `psr/http-factory` и `psr/http-server-handler`: PSR-17 factory материализует запросы, а `ContractSuite` гоняет PSR-15 handler в процессе @@ -135,7 +135,8 @@ request-направлению схемы — `readOnly`-свойства ухо | `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению | | `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна, а `multipleOf` number-ветви судит собственный `SchemaCheck::isMultipleOf()` контракта), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано | | `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed | -| `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | +| `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed | +| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | Каждая невыполнимая комбинация, которую компилятор способен распознать, отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко diff --git a/composer.json b/composer.json index 1ee895d..1de5c82 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", - "rasuvaeff/openapi-contract": "^0.12.2", + "rasuvaeff/openapi-contract": "^0.13", "rasuvaeff/property-testing-core": "^0.10 || ^0.11" }, "require-dev": { diff --git a/llms.txt b/llms.txt index ce521a7..1a91e63 100644 --- a/llms.txt +++ b/llms.txt @@ -304,3 +304,10 @@ Object generation supports bounded `minProperties`, `maxProperties`, and boolean or schema-valued `additionalProperties`. The constructive `not` subset supports `const`, `enum`, and `type` exclusions; other negative assertions fail closed. +A schema that refers to itself arrives from the contract as `$defs` plus a +local `{$ref: '#/$defs/'}` per reference back; the def unfolds three +levels below the root and ends at a leaf that leaves the recursive member out +(optional property omitted, array that may be empty left empty, branch of a +choice skipped). A def whose only way down is a required member that is the +def itself has no finite instance and fails closed; so does a `$ref` to +anything but the schema's own `$defs`. diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index d30cd65..e79c690 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -26,6 +26,7 @@ public function __construct( private SchemaArbitraryCompiler $compiler, private SchemaFacts $facts, + private Definitions $definitions, ) {} /** @param array $schema */ @@ -39,7 +40,9 @@ public function combinator(array $schema): ?ArbitraryInterface $schemas = $this->schemaBranches($schema[$keyword], $keyword); if ($keyword === 'allOf') { - return $this->compiler->compile($this->mergeAllOf($schemas)); + [$schemas, $inlined] = $this->inlineReferences($schemas); + + return $this->definitions->inlining($inlined, fn(): ArbitraryInterface => $this->compiler->compile($this->mergeAllOf($schemas))); } if ($keyword === 'oneOf' && !$this->areDisjoint($schemas)) { $numeric = $this->integerAndNumberBranches($schemas); @@ -52,7 +55,15 @@ public function combinator(array $schema): ?ArbitraryInterface $pairs = []; foreach ($schemas as $branch) { - $pairs[] = [1, $this->compiler->compile($branch)]; + try { + $pairs[] = [1, $this->compiler->compile($branch)]; + } catch (Unproducible) { + // The leaf of a recursive def: the branch that would hold + // more of it is skipped, the others still choose. + } + } + if ($pairs === []) { + throw new Unproducible($keyword); } return Gen::frequency($pairs); @@ -449,6 +460,31 @@ private function schemaBranches(mixed $value, string $keyword): array return $schemas; } + /** + * A member of a conjunction that is a reference into the schema's + * `$defs` — the contract's form for a 3.1 `$ref` with asserting siblings + * — is merged as the def's body: the conjunction is what the generated + * value has to satisfy, and a reference to a def is that def. The names + * inlined are reported so a body that puts itself into its own + * conjunction is refused rather than inlined without end. + * + * @param list> $branches + * @return array{list>, list} + */ + private function inlineReferences(array $branches): array + { + $inlined = []; + foreach ($branches as $index => $branch) { + if (array_key_exists('$ref', $branch)) { + [$name, $body] = $this->definitions->target($branch['$ref']); + $branches[$index] = $body; + $inlined[] = $name; + } + } + + return [$branches, $inlined]; + } + /** * Whether no value can satisfy two of the branches, knowable from their * declared types alone. `integer` and `number` are one class here: every diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 9910f9c..007aa3a 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -39,7 +39,17 @@ public function array(array $schema): ArbitraryInterface throw UnsupportedGeneration::forSchema('minItems exceeds maxItems or the generation budget'); } - $element = $this->compiler->compile($items); + try { + $element = $this->compiler->compile($items); + } catch (Unproducible $leaf) { + // The leaf of a recursive def: the array that holds no more of + // it ends the recursion — where the schema lets it be empty. + if ($min > 0) { + throw $leaf; + } + + return Gen::constant(value: []); + } if (($schema['uniqueItems'] ?? false) !== true) { return Gen::arrayOf($element, $min, $max); } @@ -121,7 +131,18 @@ public function object(array $schema): ArbitraryInterface continue; } - $compiled = $this->compiler->compile($property); + + $compiled = $this->producible($property); + if (!$compiled instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) { + // The leaf of a recursive def: an optional member that would + // hold more of it is left out, a required one gives it up. + if (isset($requiredNames[$name])) { + throw new Unproducible($name); + } + $reserved[$name] = true; + + continue; + } $shape[$name] = isset($requiredNames[$name]) ? $compiled : $this->optionalProperty($compiled); } foreach (array_keys($requiredNames) as $name) { @@ -242,6 +263,21 @@ private function additionalProperties( * An optional property carries a value whether or not it is present, so * the cardinality pass can bring an absent one in without a second draw. */ + /** + * The member's arbitrary, or `null` where the member is the leaf of a + * recursive def and has no value at this depth. + * + * @param array $schema + */ + private function producible(array $schema): ?ArbitraryInterface + { + try { + return $this->compiler->compile($schema); + } catch (Unproducible) { + return null; + } + } + private function optionalProperty(ArbitraryInterface $compiled): ArbitraryInterface { return Gen::record(['present' => Gen::bool(), 'value' => $compiled]); diff --git a/src/Internal/Compile/Definitions.php b/src/Internal/Compile/Definitions.php new file mode 100644 index 0000000..5ab393d --- /dev/null +++ b/src/Internal/Compile/Definitions.php @@ -0,0 +1,159 @@ +'}` + * for every reference back into the cycle. Generation unfolds that a + * bounded number of levels: a def is compiled once with its own name bound + * to the previous level's arbitrary, and once with the name bound to + * nothing — the leaf, where every reference back to it is unproducible and + * the containers around it leave it out. This is the mutable state that + * threads through the compiler's recursion; the compiler itself stays + * immutable. + * + * @internal + */ +final class Definitions +{ + /** @var array> */ + private array $defs = []; + + /** + * A def name bound to an arbitrary while its body is compiled, or to + * `null` while its leaf is: the level below the last one. + * + * @var array + */ + private array $bindings = []; + + /** @var array defs whose body is being merged into an `allOf` */ + private array $merging = []; + + /** + * Runs $compile with the `$defs` of a Schema Object root in scope. Roots + * do not nest — a `$defs` map sits on the root the contract emits it on, + * and every reference into it is resolved against that one map. + * + * @template T + * + * @param array $defs + * @param callable(): T $compile + * + * @return T + */ + public function within(array $defs, callable $compile): mixed + { + $outer = $this->defs; + /** @var mixed $body */ + foreach ($defs as $name => $body) { + if (!is_array($body) || ($body !== [] && array_is_list($body))) { + throw UnsupportedGeneration::forSchema(sprintf('$defs member "%s" must be a schema object', (string) $name)); + } + /** @var array $body */ + $this->defs[(string) $name] = $body; + } + + try { + return $compile(); + } finally { + $this->defs = $outer; + } + } + + /** + * The def a local reference names, by the pointer the contract wrote — + * `#/$defs/components.schemas.Node`, the name JSON-Pointer-escaped. + * + * @return array{string, array} the name and its body + */ + public function target(mixed $reference): array + { + if (!is_string($reference) || !str_starts_with($reference, '#/$defs/')) { + throw UnsupportedGeneration::forSchema(sprintf( + '$ref "%s" is not a local reference into the schema\'s $defs', + is_string($reference) ? $reference : get_debug_type($reference), + )); + } + /** @var non-empty-string $reference */ + $name = str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen('#/$defs/'))); + if (!array_key_exists($name, $this->defs)) { + throw UnsupportedGeneration::forSchema(sprintf('$ref "%s" names no $defs member', $reference)); + } + + return [$name, $this->defs[$name]]; + } + + public function isBound(string $name): bool + { + return array_key_exists($name, $this->bindings); + } + + /** + * What a reference to a bound def compiles to: the previous level, or + * nothing at the leaf. + */ + public function bound(string $name): ?ArbitraryInterface + { + return $this->bindings[$name] ?? null; + } + + /** + * Runs $compile with $name bound — to the level below, or to nothing. + * + * @template T + * + * @param callable(): T $compile + * + * @return T + */ + public function bind(string $name, ?ArbitraryInterface $level, callable $compile): mixed + { + $this->bindings[$name] = $level; + + try { + return $compile(); + } finally { + unset($this->bindings[$name]); + } + } + + /** + * Runs $compile with $names marked as inlined into an `allOf`, so a body + * that puts itself into its own conjunction is refused rather than + * inlined without end. + * + * @template T + * + * @param list $names + * @param callable(): T $compile + * + * @return T + */ + public function inlining(array $names, callable $compile): mixed + { + foreach ($names as $name) { + if (isset($this->merging[$name])) { + throw UnsupportedGeneration::forSchema(sprintf('$defs member "%s" is an allOf member of itself', $name)); + } + $this->merging[$name] = true; + } + + try { + return $compile(); + } finally { + foreach ($names as $name) { + unset($this->merging[$name]); + } + } + } +} diff --git a/src/Internal/Compile/Unproducible.php b/src/Internal/Compile/Unproducible.php new file mode 100644 index 0000000..82dc3e4 --- /dev/null +++ b/src/Internal/Compile/Unproducible.php @@ -0,0 +1,19 @@ + $schema */ public function jsonValue(mixed $value, array $schema): mixed { + $defs = $this->schemaObject($schema['$defs'] ?? [], 'Schema $defs must be an object'); + + return $this->jsonValueIn($value, $schema, $defs); + } + + /** + * @param array $schema + * @param array $defs the `$defs` of the schema root: what a + * local `$ref` below it — the contract's form for a schema that + * refers to itself — is read as, so a nested empty object is still + * known to be an object + */ + private function jsonValueIn(mixed $value, array $schema, array $defs): mixed + { + if (isset($schema['$ref'])) { + $schema = $this->dereference($schema['$ref'], $defs); + } if (SchemaShape::isArray($schema) && is_array($value) && array_is_list($value)) { $items = $this->schemaObject($schema['items'] ?? null, 'Array items must be a schema object'); - return array_map(fn(mixed $item): mixed => $this->jsonValue($item, $items), $value); + return array_map(fn(mixed $item): mixed => $this->jsonValueIn($item, $items, $defs), $value); } // `array_is_list()` is not a guess here: a negative case deliberately // sends a list where an object is declared, and the wire has to carry @@ -54,7 +71,7 @@ public function jsonValue(mixed $value, array $schema): mixed // normalized back either way, and `json_encode()` renders an // integer key as the string name it came from. $property = $this->schemaObject($properties[$name] ?? $this->additionalSchema($schema), 'Object property must be a schema object'); - $result = array_replace($result, [$name => $this->jsonValue($value[$name], $property)]); + $result = array_replace($result, [$name => $this->jsonValueIn($value[$name], $property, $defs)]); } return (object) $result; @@ -63,6 +80,25 @@ public function jsonValue(mixed $value, array $schema): mixed return $value; } + /** + * The def a local reference names — `#/$defs/`, the name + * JSON-Pointer-escaped — which is the only `$ref` a compiled schema + * carries. + * + * @param array $defs + * @return array + */ + private function dereference(mixed $reference, array $defs): array + { + if (!is_string($reference) || !str_starts_with($reference, '#/$defs/')) { + throw new InvalidCase('Schema $ref must be a local reference into $defs'); + } + /** @var non-empty-string $reference */ + $name = str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen('#/$defs/'))); + + return $this->schemaObject($defs[$name] ?? null, sprintf('Schema $ref "%s" names no $defs member', $reference)); + } + /** * The schema a key outside `properties` is declared by. Losing it encoded a * nested empty object as `[]`, which the contract then rejected. diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index e3c5eee..1bf3d91 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -9,8 +9,10 @@ use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ContainerArbitraries; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\Definitions; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ScalarArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\SchemaFacts; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\Unproducible; /** * Compiles the explicit JSON-compatible schema subset into shrinkable values. @@ -19,6 +21,13 @@ */ final readonly class SchemaArbitraryCompiler { + /** + * How many levels a recursive def unfolds before its leaf: a tree three + * nodes deep, the breadth of each level bounded by the containers' + * collection budget. + */ + private const int RECURSION_DEPTH = 3; + private SchemaFacts $facts; private CompositionArbitraries $composition; @@ -27,6 +36,8 @@ private ContainerArbitraries $containers; + private Definitions $definitions; + /** * @param string $excludedCharacters characters no generated plain string * may contain — the separator of a delimited parameter style, which @@ -42,7 +53,8 @@ public function __construct(string $excludedCharacters = '', ?SchemaDirection $d { $facts = new SchemaFacts(); $this->facts = $facts; - $this->composition = new CompositionArbitraries($this, $facts); + $this->definitions = new Definitions(); + $this->composition = new CompositionArbitraries($this, $facts, $this->definitions); $this->scalars = new ScalarArbitraries($facts, $excludedCharacters); $this->containers = new ContainerArbitraries($this, $facts, $direction); } @@ -52,6 +64,18 @@ public function __construct(string $excludedCharacters = '', ?SchemaDirection $d */ public function compile(array $schema): ArbitraryInterface { + if (array_key_exists('$defs', $schema)) { + $defs = $schema['$defs']; + if (!is_array($defs)) { + throw UnsupportedGeneration::forSchema('$defs must be an object of schemas'); + } + unset($schema['$defs']); + + return $this->definitions->within($defs, fn(): ArbitraryInterface => $this->compile($schema)); + } + if (array_key_exists('$ref', $schema)) { + return $this->reference($schema['$ref']); + } $combinator = $this->composition->combinator($schema); if ($combinator instanceof ArbitraryInterface) { return $combinator; @@ -114,6 +138,35 @@ public function compile(array $schema): ArbitraryInterface }; } + /** + * A local reference into the schema's `$defs`: the compiled form of a + * schema that refers to itself. The def unfolds {@see RECURSION_DEPTH} + * levels, each compiled with the def's own name bound to the level + * below, down to a leaf compiled with the name bound to nothing — where + * a reference back to the def is unproducible and the containers around + * it leave it out. A def whose leaf has no value at all (a required + * member that is the def itself) has no finite instance, and is refused. + */ + private function reference(mixed $reference): ArbitraryInterface + { + [$name, $body] = $this->definitions->target($reference); + if ($this->definitions->isBound($name)) { + return $this->definitions->bound($name) ?? throw new Unproducible($name); + } + + try { + $leaf = $this->definitions->bind($name, null, fn(): ArbitraryInterface => $this->compile($body)); + } catch (Unproducible) { + throw UnsupportedGeneration::forSchema(sprintf('recursive schema "%s" has no finite instance', $name)); + } + + return Gen::recursive( + $leaf, + fn(ArbitraryInterface $level): ArbitraryInterface => $this->definitions->bind($name, $level, fn(): ArbitraryInterface => $this->compile($body)), + self::RECURSION_DEPTH, + ); + } + /** @param array $schema */ private function type(array $schema): string { @@ -142,7 +195,7 @@ private function type(array $schema): string private function assertSupported(array $schema): void { foreach ([ - '$ref', 'allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', + 'allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', 'contains', 'prefixItems', 'patternProperties', 'propertyNames', 'unevaluatedProperties', ] as $keyword) { diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 7323afc..d1bd829 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -6,12 +6,16 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Rasuvaeff\OpenApiContract\Contract; +use Rasuvaeff\OpenApiContract\SchemaCheck; +use Rasuvaeff\OpenApiContract\SchemaDialect; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ContainerArbitraries; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\Definitions; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ScalarArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\SchemaFacts; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\JsonBodyEncoder; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; use Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler; use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; @@ -26,6 +30,7 @@ #[Covers(SchemaArbitraryCompiler::class)] #[Covers(CompositionArbitraries::class)] #[Covers(ContainerArbitraries::class)] +#[Covers(Definitions::class)] #[Covers(ScalarArbitraries::class)] #[Covers(SchemaFacts::class)] #[Covers(UnsupportedGeneration::class)] @@ -452,7 +457,7 @@ public function rejectsUnsupportedAssertionKeywords(string $keyword): void /** @return iterable */ public static function unsupportedKeywordCases(): iterable { - foreach (['$ref', 'allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', 'contains', 'prefixItems', 'patternProperties', 'propertyNames', 'unevaluatedProperties'] as $keyword) { + foreach (['allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', 'contains', 'prefixItems', 'patternProperties', 'propertyNames', 'unevaluatedProperties'] as $keyword) { yield $keyword => [$keyword]; } } @@ -1013,7 +1018,11 @@ public function reportsExactMessagesForUnsupportedSchemas(): void foreach ([ [['type' => 'string', 'not' => []], 'not cannot combine const and enum'], [['type' => 'string', 'not' => ['x']], 'not must be a schema object'], - [['$ref' => []], 'keyword "$ref" is outside the initial support matrix'], + [['$ref' => []], '$ref "array" is not a local reference into the schema\'s $defs'], + [['$ref' => '#/components/schemas/Node'], '$ref "#/components/schemas/Node" is not a local reference into the schema\'s $defs'], + [['$ref' => '#/$defs/Node'], '$ref "#/$defs/Node" names no $defs member'], + [['$defs' => 'x', 'type' => 'string'], '$defs must be an object of schemas'], + [['$defs' => ['Node' => 'x'], 'type' => 'string'], '$defs member "Node" must be a schema object'], [['type' => 'object', 'properties' => ['id']], 'object properties must be an object'], [['type' => ['string', 42]], 'a type, properties, or items declaration is required'], [['type' => 'string', 'format' => 42], 'format must be a string'], @@ -1738,6 +1747,147 @@ public function checksAdditionalPropertiesShapeOnEveryType(): void (new SchemaArbitraryCompiler())->compile(['type' => 'string', 'additionalProperties' => ['x']]); } + /** + * The contract compiles a schema that refers to itself as `$defs` plus a + * local `$ref` for every reference back into the cycle. Generation + * unfolds it a bounded number of levels and ends at a leaf that leaves + * the recursive member out — so every value is finite, every value + * satisfies the schema, and the depth is actually reached. + */ + public function generatesARecursiveSchemaToABoundedDepth(): void + { + $node = [ + 'type' => 'object', + 'required' => ['name'], + 'additionalProperties' => false, + 'properties' => [ + 'name' => ['type' => 'string', 'maxLength' => 3], + 'children' => ['type' => 'array', 'maxItems' => 2, 'items' => ['$ref' => '#/$defs/components.schemas.Node', 'type' => 'object']], + ], + ]; + $schema = [...$node, '$defs' => ['components.schemas.Node' => $node]]; + $check = new SchemaCheck(); + $deepest = 0; + $depth = static function (array $value) use (&$depth): int { + $below = 0; + foreach ($value['children'] ?? [] as $child) { + $below = max($below, $depth($child)); + } + + return 1 + $below; + }; + + $encoder = new JsonBodyEncoder(); + foreach (Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 200, seed: 7) as $value) { + Assert::true(is_array($value)); + Assert::true($check->accepts(json_decode($encoder->encode($value, $schema)), $schema, SchemaDialect::OpenApi31)); + $deepest = max($deepest, $depth($value)); + } + // The root, three unfolded levels, and the leaf. + Assert::same($deepest, 5); + } + + /** + * A def name is whatever pointer the contract wrote, JSON-Pointer-escaped + * in the `$ref` — a cross-file def is `a.json:Node`, and a name may hold + * `/` or `~`; the empty def is the unconstrained schema. + */ + public function readsTheDefNameOffTheEscapedPointer(): void + { + $schema = [ + 'type' => 'object', + 'required' => ['a', 'b'], + 'properties' => [ + 'a' => ['$ref' => '#/$defs/a~1b~0c'], + 'b' => ['$ref' => '#/$defs/any'], + ], + '$defs' => ['a/b~c' => ['type' => 'integer', 'minimum' => 3, 'maximum' => 3], 'any' => []], + ]; + + foreach (Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 5, seed: 1) as $value) { + Assert::same($value['a'], 3); + Assert::true(array_key_exists('b', $value)); + } + } + + /** + * The leaf ends the recursion wherever the schema gives it a way out: an + * optional member left out, an array allowed to be empty, a branch of a + * choice skipped, a def that merely aliases another. A def whose only + * way down is a required member that is the def itself has no finite + * instance, and is refused with the def named. + */ + #[DataProvider('recursiveShapes')] + public function endsTheRecursionWhereTheSchemaAllowsIt(array $defs, array $root, ?string $refusal): void + { + $schema = [...$root, '$defs' => $defs]; + if ($refusal !== null) { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: ' . $refusal); + } + + $arbitrary = (new SchemaArbitraryCompiler())->compile($schema); + + $check = new SchemaCheck(); + $encoder = new JsonBodyEncoder(); + foreach (Gen::sample($arbitrary, count: 60, seed: 3) as $value) { + Assert::true($check->accepts(json_decode($encoder->encode($value, $schema)), $schema, SchemaDialect::OpenApi31)); + } + } + + /** @return iterable>, array, ?string}> */ + public static function recursiveShapes(): iterable + { + $ref = ['$ref' => '#/$defs/N', 'type' => 'object']; + yield 'optional member' => [ + ['N' => ['type' => 'object', 'properties' => ['next' => $ref]]], + ['type' => 'object', 'properties' => ['next' => $ref]], + null, + ]; + yield 'array that may be empty' => [ + ['N' => ['type' => 'object', 'required' => ['items'], 'properties' => ['items' => ['type' => 'array', 'maxItems' => 2, 'items' => $ref]]]], + ['type' => 'object', 'required' => ['items'], 'properties' => ['items' => ['type' => 'array', 'maxItems' => 2, 'items' => $ref]]], + null, + ]; + yield 'a choice with a way out' => [ + ['N' => ['type' => 'object', 'required' => ['v'], 'properties' => ['v' => ['oneOf' => [['type' => 'integer'], $ref]]]]], + ['type' => 'object', 'required' => ['v'], 'properties' => ['v' => ['anyOf' => [['type' => 'string'], $ref]]]], + null, + ]; + yield 'mutual recursion' => [ + [ + 'A' => ['type' => 'object', 'properties' => ['b' => ['$ref' => '#/$defs/B', 'type' => 'object']]], + 'B' => ['type' => 'object', 'required' => ['a'], 'properties' => ['a' => ['$ref' => '#/$defs/A', 'type' => 'object'], 'tag' => ['type' => 'string']]], + ], + ['type' => 'object', 'properties' => ['b' => ['$ref' => '#/$defs/B', 'type' => 'object']]], + null, + ]; + yield 'a conjunction with the def' => [ + ['N' => ['type' => 'object', 'required' => ['n'], 'properties' => ['next' => $ref, 'n' => ['type' => 'integer']]]], + ['allOf' => [$ref, ['properties' => ['n' => ['minimum' => 1, 'maximum' => 3]]]]], + null, + ]; + yield 'a required member that is the def itself' => [ + ['N' => ['type' => 'object', 'required' => ['next'], 'properties' => ['next' => $ref]]], + ['type' => 'object', 'properties' => ['root' => $ref]], + 'recursive schema "N" has no finite instance', + ]; + yield 'an array that may not be empty' => [ + ['N' => ['type' => 'array', 'minItems' => 1, 'items' => $ref]], + ['type' => 'array', 'items' => $ref], + 'recursive schema "N" has no finite instance', + ]; + yield 'a choice with no way out' => [ + ['N' => ['type' => 'object', 'required' => ['v'], 'properties' => ['v' => ['anyOf' => [$ref, ['$ref' => '#/$defs/N', 'type' => 'object']]]]]], + ['type' => 'object', 'properties' => ['root' => $ref]], + 'recursive schema "N" has no finite instance', + ]; + yield 'a def in its own conjunction' => [ + ['N' => ['allOf' => [$ref]]], + ['type' => 'object', 'properties' => ['root' => $ref]], + '$defs member "N" is an allOf member of itself', + ]; + } + public function mergesObjectAllOfBranches(): void { $arbitrary = (new SchemaArbitraryCompiler())->compile([ diff --git a/tests/Support/ZooContracts.php b/tests/Support/ZooContracts.php index daf1e65..d2472d6 100644 --- a/tests/Support/ZooContracts.php +++ b/tests/Support/ZooContracts.php @@ -30,6 +30,7 @@ final class ZooContracts 'encoded.create', 'mixed.create', 'numeric.create', 'headers.get', 'verified.get', 'search.get', 'narrowed.create', 'bounded.create', 'pages.get', 'profiles.create', 'labels.get', 'sparse.create', 'amounts.create', 'settings.create', 'precise.get', + 'tree.create', ]; /** @@ -442,7 +443,28 @@ public static function document(): array ], 'responses' => ['204' => []], ]], + // A schema that refers to itself: the contract compiles it to + // `$defs` plus a local `$ref` for the reference back, and the + // generator unfolds it a bounded number of levels — the leaf + // leaves `children` empty and `parent` out. `id` is readOnly + // at every depth. + '/tree' => ['post' => [ + 'operationId' => 'tree.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Node']]]], + 'responses' => ['201' => ['description' => 'created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Node']]]]], + ]], ], + 'components' => ['schemas' => ['Node' => [ + 'type' => 'object', + 'required' => ['id', 'name'], + 'additionalProperties' => false, + 'properties' => [ + 'id' => ['type' => 'integer', 'readOnly' => true], + 'name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 4], + 'parent' => ['$ref' => '#/components/schemas/Node'], + 'children' => ['type' => 'array', 'maxItems' => 2, 'items' => ['$ref' => '#/components/schemas/Node']], + ], + ]]], ]; } @@ -537,6 +559,7 @@ public static function transport(): CallableTransport return match (true) { str_starts_with($path, '/strings/') => new Response(200, ['Content-Type' => 'application/json'], '{}'), $path === '/users' => new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"name":"Ann","profile":{"slug":"ann","createdAt":"2024-01-01T00:00:00Z"}}'), + $path === '/tree' => new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"name":"root","children":[{"id":2,"name":"leaf","children":[]}]}'), $path === '/health' => new Response(200, ['Content-Type' => 'text/plain'], 'ok'), $path === '/version' => new Response(200, ['Content-Type' => 'text/plain; charset=utf-8'], 'v1'), str_starts_with($path, '/files/') => new Response(200, ['Content-Type' => 'application/octet-stream'], "\x00\x01"),