From 51bdf5bc7af4828362eb03c584a7265788d0df14 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 15:11:58 +1000 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20Close=20YAML=20chain=20billion-lau?= =?UTF-8?q?ghs=20DoS=20(B-SEC-1)=20(Task=2001=20=E2=80=94=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lower MAX_ALIAS_DEPTH 10→4 (chain-bomb defence; rejects all known chain-bomb variants while preserving legitimate 4-level dedup patterns 1→2→4→8 ≤10^4 expanded elements). - Add MAX_EXPANSION_BYTES = 5_000_000 post-parse defense-in-depth cap; catches horizontal bombs (low chain depth × high arity) that bypass the pre-parse DAG heuristic. - Add SpecTooLargeException::forExpansionSize(int $cap, int $actual) factory with sanitised message (only metric + actual + cap; never attacker payload — CWE-209). - Add normalizeLineEndings() in assertNoAnchorBomb() to defend against \r/\r\n line-ending bypass of the DAG depth heuristic (Symfony Parser applies the same str_replace, so the heuristic stays consistent with Symfony's view). - README: update caps table (MAX_ALIAS_DEPTH=4, MAX_EXPANSION_BYTES row) + intro paragraph mentioning the fourth post-parse cap. - Add 5 regression tests: 7×10 chain bomb (<100 ms rejection), 4×10 legitimate dedup still passes, 4×40 horizontal bomb caught by size cap, CR/LF and CR-only line-ending chain bombs rejected by depth cap. Refs: B-SEC-1 (CVSS 7.5, CWE-400 / CWE-770). Audit: 505-byte anchor-chain payload previously caused ~17 s CPU + ~1.5 GB RAM per parse; same payload now rejected in <5 ms. Verified: 7136 tests OK, Psalm 99.6790% (no errors), cs-fix/rector clean. --- README.md | 8 +- src/Schema/Parser/YamlParser.php | 42 ++++-- .../Exception/SpecTooLargeException.php | 13 ++ .../Unit/Schema/Parser/YamlParserBombTest.php | 125 ++++++++++++++++++ 4 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/Schema/Parser/YamlParserBombTest.php diff --git a/README.md b/README.md index 2c37c84..3a8df09 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,17 @@ and alias (`*name`) constructs to block the "billion laughs" expansion bomb document. The pre-parse scan runs after the size check and before `Symfony\Component\Yaml\Yaml::parse()`, so an attacker-controlled 1 KB payload can never reach the parser even when its expanded in-memory size would exceed -the process `memory_limit`. +the process `memory_limit`. A fourth post-parse cap (`MAX_EXPANSION_BYTES`) +acts as defense-in-depth for bombs whose chain depth slips under the DAG +heuristic but whose horizontal expansion (high arity at low depth) would +produce an oversized in-memory payload after `Yaml::parse()`. | Cap | Default | Rationale | |-----|---------|-----------| | `YamlParser::MAX_ANCHORS` | 100 | Real OpenAPI specs use fewer than 20 anchors for schema deduplication. The regex scanner uses `[^ \t,\[\]\{\}\n]+` with `/u` flag, exactly mirroring Symfony YAML's `Inline::parseAnchor` reject set — so any character Symfony accepts as an anchor-name character (Cyrillic, CJK, dots, colons, pipes, FF, VT, NBSP, etc.) is counted. | | `YamlParser::MAX_ALIASES` | 1000 | Real OpenAPI specs use fewer than 50 alias references. Symfony YAML's own `maxAliasesForCollections` (default 128) remains active as defense-in-depth for collection aliases that slip past the pre-parse scan. | -| `YamlParser::MAX_ALIAS_DEPTH` | 10 | DAG-based longest-chain heuristic. Each anchor's value range is determined by indentation (from the anchor's declaration line to the next anchor at the same or lower indentation). Aliases within that range that reference other declared anchors become DAG edges; the longest path is the chain depth. Catches both same-line (flow-style `b: &b [*a]`) and multi-line (`b: &b\n - *a`) billion-laughs variants. Real billion-laughs payloads use 5-7 chain levels; 10 leaves conservative headroom for legitimate deduplication. | +| `YamlParser::MAX_ALIAS_DEPTH` | **4** | DAG-based longest-chain heuristic. Each anchor's value range is determined by indentation (from the anchor's declaration line to the next anchor at the same or lower indentation). Aliases within that range that reference other declared anchors become DAG edges; the longest path is the chain depth. Catches both same-line (flow-style `b: &b [*a]`) and multi-line (`b: &b\n - *a`) billion-laughs variants. Real billion-laughs payloads use 5-7 chain levels; **4 rejects all known chain-bomb variants while preserving legitimate 4-level dedup patterns** (1→2→4→8 aliases, ≤10⁴ expanded elements — covers any reasonable spec). | +| `YamlParser::MAX_EXPANSION_BYTES` | **5_000_000** | Post-parse defense-in-depth cap. Catches horizontal bombs (high arity × low chain depth) that bypass the DAG heuristic. Compares `strlen(serialize($parsed))` against the cap and throws `SpecTooLargeException` after `Symfony\Component\Yaml\Yaml::parse()` returns. Real OpenAPI specs serialize to <2 MB after expansion. | Exceeding any cap throws `SpecTooLargeException` (a `\RuntimeException` subclass) with a sanitised message that discloses only the metric, the actual diff --git a/src/Schema/Parser/YamlParser.php b/src/Schema/Parser/YamlParser.php index c884ac1..7b10677 100644 --- a/src/Schema/Parser/YamlParser.php +++ b/src/Schema/Parser/YamlParser.php @@ -18,6 +18,8 @@ use function is_string; use function ltrim; use function min; +use function serialize; +use function str_replace; use function strlen; final class YamlParser extends OpenApiBuilder @@ -27,18 +29,26 @@ final class YamlParser extends OpenApiBuilder public const int DEFAULT_MAX_SPEC_DEPTH = 100; /** - * Conservative caps for YAML anchor/alias expansion bombs - * (billion-laughs attack, CWE-400, CWE-770). Real OpenAPI specs - * typically use fewer than 20 anchors and 50 aliases for schema - * deduplication; these caps allow legitimate deduplication while - * rejecting exponential blowup before the Symfony YAML parser - * materialises the expanded document. + * Pre-parse caps for YAML anchor/alias expansion bombs (billion-laughs + * attack, CWE-400, CWE-770). Real OpenAPI specs typically use fewer + * than 20 anchors and 50 aliases for schema deduplication; these caps + * allow legitimate deduplication while rejecting exponential blowup + * before the Symfony YAML parser materialises the expanded document. */ public const int MAX_ANCHORS = 100; public const int MAX_ALIASES = 1000; - public const int MAX_ALIAS_DEPTH = 10; + public const int MAX_ALIAS_DEPTH = 4; + + /** + * Post-parse defense-in-depth cap: measures strlen(serialize($parsed)) + * AFTER Yaml::parse() returns. Catches horizontal bombs (high arity x + * low chain depth) that slip the pre-parse DAG heuristic; the + * materialisation cost is the accepted tradeoff, bounded by the + * pre-parse caps above. + */ + public const int MAX_EXPANSION_BYTES = 5_000_000; private readonly PregExecutor $pregExecutor; @@ -64,6 +74,11 @@ protected function parseContent(string $content): mixed /** @var mixed $data */ $data = Yaml::parse($content, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE); + $serializedSize = strlen(serialize($data)); + if ($serializedSize > self::MAX_EXPANSION_BYTES) { + throw SpecTooLargeException::forExpansionSize(self::MAX_EXPANSION_BYTES, $serializedSize); + } + if (is_array($data)) { $depth = $this->calculateDepth($data); if ($depth > $this->maxSpecDepth) { @@ -104,22 +119,29 @@ private function calculateDepth(array $data, int $current = 0): int private function assertNoAnchorBomb(string $content): void { - $anchorCount = $this->countAnchors($content); + $normalized = $this->normalizeLineEndings($content); + + $anchorCount = $this->countAnchors($normalized); if ($anchorCount > self::MAX_ANCHORS) { throw SpecTooLargeException::forAnchorCount(self::MAX_ANCHORS, $anchorCount); } - $aliasCount = $this->countAliases($content); + $aliasCount = $this->countAliases($normalized); if ($aliasCount > self::MAX_ALIASES) { throw SpecTooLargeException::forAliasCount(self::MAX_ALIASES, $aliasCount); } - $aliasDepth = $this->estimateAliasNestingDepth($content); + $aliasDepth = $this->estimateAliasNestingDepth($normalized); if ($aliasDepth > self::MAX_ALIAS_DEPTH) { throw SpecTooLargeException::forAliasDepth(self::MAX_ALIAS_DEPTH, $aliasDepth); } } + private function normalizeLineEndings(string $content): string + { + return str_replace(["\r\n", "\r"], "\n", $content); + } + private function countAnchors(string $content): int { $result = $this->pregExecutor->matchAll('/(?parser = new YamlParser(); + } + + #[Test] + public function seven_level_ten_arity_chain_bomb_is_rejected_under_100ms(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 10); + + $start = hrtime(true); + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + $elapsedNs = hrtime(true) - $start; + $elapsedMs = (int) ($elapsedNs / 1_000_000); + self::assertLessThan( + 100, + $elapsedMs, + sprintf('Billion-laughs defence took %d ms; expected <100 ms', $elapsedMs), + ); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function four_level_ten_arity_legitimate_dedup_still_passes(): void + { + $payload = $this->buildChainBomb(levels: 4, arity: 10); + + $document = $this->parser->parse($payload); + + self::assertSame('3.0.3', $document->openapi); + self::assertNotNull($document->components); + self::assertNotNull($document->components->schemas); + self::assertArrayHasKey('lvl0', $document->components->schemas); + self::assertArrayHasKey('lvl3', $document->components->schemas); + } + + #[Test] + public function horizontal_bomb_at_depth_cap_high_arity_caught_by_size_cap(): void + { + $payload = $this->buildChainBomb(levels: 4, arity: 40); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('Expanded YAML payload of', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function cr_lf_line_ending_chain_bomb_is_rejected(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 5, lineSeparator: "\r\n"); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('alias nesting too deep', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function cr_only_line_ending_chain_bomb_is_rejected(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 5, lineSeparator: "\r"); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('alias nesting too deep', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + private function buildChainBomb(int $levels, int $arity, string $lineSeparator = "\n"): string + { + $lines = []; + $inner = implode(',', array_fill(0, $arity, '"x"')); + $lines[] = "lvl0: &lvl0 [{$inner}]"; + + for ($i = 1; $i < $levels; ++$i) { + $refs = implode(',', array_fill(0, $arity, "*lvl" . ($i - 1))); + $lines[] = "lvl{$i}: &lvl{$i} [{$refs}]"; + } + + $header = "openapi: 3.0.3" + . $lineSeparator . "info:" + . $lineSeparator . " title: Bomb" + . $lineSeparator . " version: 1.0.0" + . $lineSeparator . "paths: {}" + . $lineSeparator . "components:" + . $lineSeparator . " schemas:" + . $lineSeparator . " "; + + return $header . implode($lineSeparator . " ", $lines) . $lineSeparator; + } +} From 68368e46aa82ff4c5aea43dbd82d99ec2445c9df Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 15:31:58 +1000 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20Tighten=20symfony/yaml=20constrain?= =?UTF-8?q?t=20to=20^7.4=20||=20^8.1=20(B-DEP-1)=20(Task=2002=20=E2=80=94?= =?UTF-8?q?=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - composer.json:34 — change constraint from `^7.0 || ^8.0` to `^7.4 || ^8.1` - Blocks EOL versions: Symfony 7.0-7.3 (no security patches since 2024-2025) and 8.0 (EOL 31 Jul 2026). - Preserves Symfony 7.4 LTS (security support to Nov 2029) and 8.1 current. - composer.lock (v8.1.1) remains valid; no lock file changes in commit (lock file in .gitignore per library convention). Refs: B-DEP-1 (MEDIUM supply-chain blocker). Verified: composer validate --strict exit 0, composer update --dry-run 'Nothing to modify in lock file', composer why-not 7.0/7.3/8.0 blocked, 7.4 allowed, make tests 7136 OK. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 487b3be..0277d8a 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/event-dispatcher": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/yaml": "^7.0 || ^8.0" + "symfony/yaml": "^7.4 || ^8.1" }, "require-dev": { "phpunit/phpunit": "^13.0", From 3629d040c6d811248652446e0ba3b19d1614f87e Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 16:14:29 +1000 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20Widen=20Swoole=20CI=20filter=20to?= =?UTF-8?q?=20include=20ValidatorPoolTest=20(B-CONC-1)=20(Task=2003=20?= =?UTF-8?q?=E2=80=94=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/ci.yml:93 — change Swoole CI job filter from 'SwooleSharedValidatorTest' to '(SwooleSharedValidatorTest|ValidatorPoolTest)'. - Adds CI regression protection for ValidatorPool::forCoroutineRuntime() + Swoole\Lock serialization contract (O-004 non-reentrant deadlock). - Previously only sequential SwooleSharedValidatorTest ran in CI; the swoole_lock_serializes_concurrent_get_or_create_factory_called_once coroutine test (tests/Unit/Validator/ValidatorPoolTest.php:456) was excluded by the filter and had zero CI coverage. Refs: B-CONC-1 (MEDIUM concurrency blocker). Verified: list-tests shows 30 methods (2 SwooleSharedValidatorTest + 28 ValidatorPoolTest including target swoole_lock test); make tests 7136 OK without regressions; PCRE alternation syntax valid in PHPUnit --filter. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fb10ff..84e320c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: run: php -m | grep -q '^swoole$' || (echo "Swoole extension not loaded" && exit 1) - name: Run Swoole concurrency tests - run: php -d memory_limit=512M vendor/bin/phpunit --filter='SwooleSharedValidatorTest' --testdox --colors=always + run: php -d memory_limit=512M vendor/bin/phpunit --filter='(SwooleSharedValidatorTest|ValidatorPoolTest)' --testdox --colors=always # FrankenPHP worker-SAPI concurrency coverage is tracked as a follow-up # to R4-TEST-001. The frankenphp extension is statically compiled into From 4fb99efd82c1c6f93049066befff9e5cdb86dca1 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 16:25:28 +1000 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20Add=20missing=20[0.7.0]=20link=20r?= =?UTF-8?q?eference=20in=20CHANGELOG=20footer=20(B-DOC-1)=20(Task=2004=20?= =?UTF-8?q?=E2=80=94=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md footer: add '[0.7.0]: https://github.com/duyler/openapi/compare/0.6.0...0.7.0' between [Unreleased] and [0.6.0] references. - Eliminates broken Markdown link-reference for header ## [0.7.0] (line 8) — Keep a Changelog violation. - Header ## [0.7.0] without date intentionally preserved (date added at tag time per Task 09 release notes draft). - [Unreleased] URL left unchanged per Task 04 spec target shape: 0.7.0 is pending release (no tag yet, no date in header); Task 09 will update [Unreleased] to compare/1.0.0...HEAD when 1.0.0 is drafted. Refs: B-DOC-1 (MEDIUM documentation blocker). Verified: git diff shows +1 -0; all 6 version headers (0.7.0/0.6.0/0.5.0/0.4.1/0.4.0/0.3.3) have matching [X.Y.Z]: references; no other broken links discovered. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e73..dbd2582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -677,6 +677,7 @@ fail-closes on unresolvable callback expressions. - Set `symfony/yaml` requirement to `^7.0`. [Unreleased]: https://github.com/duyler/openapi/compare/0.6.0...HEAD +[0.7.0]: https://github.com/duyler/openapi/compare/0.6.0...0.7.0 [0.6.0]: https://github.com/duyler/openapi/compare/0.5.0...0.6.0 [0.5.0]: https://github.com/duyler/openapi/compare/0.4.1...0.5.0 [0.4.1]: https://github.com/duyler/openapi/compare/0.4.0...0.4.1 From 896a1755931334583a94bbcc45655d762e3fb404 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 16:42:13 +1000 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20Mark=2012=20Internal\=20classes=20?= =?UTF-8?q?with=20@internal=20PHPDoc=20tag=20(B-API-1)=20(Task=2005=20?= =?UTF-8?q?=E2=80=94=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /** @internal */ PHPDoc tag to 12 classes in src/Builder/Internal/ (3) and src/Compiler/Internal/ (9) that lacked the marker. - Format follows canonical samples (DocumentFingerprinter.php, SchemaHasher.php): single-line /** @internal */ for classes without existing PHPDoc; @internal tag appended to existing PHPDoc block otherwise. - After this change all 14 Internal\ classes in Builder/Internal + Compiler/Internal carry @internal marker (12 modified + 2 existing canonical). - Psalm/PHPStan will flag user dependencies on these classes once psalm/internal_plugin is enabled (currently documentation-only; plugin install is tracked as separate follow-up). Refs: B-API-1 (MEDIUM public-API stability blocker). Verified: grep @internal returns 14 files in src/{Builder,Compiler}/Internal/; make psalm 0 errors (99.6790% baseline preserved); make tests 7136 OK without regressions; make cs-fix 0 files modified. --- src/Builder/Internal/CacheKeyBuilder.php | 1 + src/Builder/Internal/ExternalRefDetector.php | 2 ++ src/Builder/Internal/SpecLoader.php | 1 + src/Compiler/Internal/ArrayConstraints.php | 2 ++ src/Compiler/Internal/EqualityHelpers.php | 2 ++ src/Compiler/Internal/FloatQuotientContext.php | 2 ++ src/Compiler/Internal/MultipleOfContext.php | 2 ++ src/Compiler/Internal/ObjectConstraints.php | 2 ++ src/Compiler/Internal/PatternCheck.php | 2 ++ src/Compiler/Internal/ScalarConstraints.php | 2 ++ src/Compiler/Internal/UnsupportedKeywordDetector.php | 2 ++ src/Compiler/Internal/Utf16Length.php | 2 ++ 12 files changed, 22 insertions(+) diff --git a/src/Builder/Internal/CacheKeyBuilder.php b/src/Builder/Internal/CacheKeyBuilder.php index 79fcfbc..4903802 100644 --- a/src/Builder/Internal/CacheKeyBuilder.php +++ b/src/Builder/Internal/CacheKeyBuilder.php @@ -12,6 +12,7 @@ use function realpath; use function sprintf; +/** @internal */ final readonly class CacheKeyBuilder { public const string FILE_PREFIX = 'openapi_spec_file_'; diff --git a/src/Builder/Internal/ExternalRefDetector.php b/src/Builder/Internal/ExternalRefDetector.php index d936736..2cf3249 100644 --- a/src/Builder/Internal/ExternalRefDetector.php +++ b/src/Builder/Internal/ExternalRefDetector.php @@ -38,6 +38,8 @@ * File-loaded specs skip the scan: their `externalRefAllowedRoot` is auto * derived from `dirname(realpath($path))` and the `FileExternalRefResolver` * enforces the boundary at resolution time. + * + * @internal */ final readonly class ExternalRefDetector { diff --git a/src/Builder/Internal/SpecLoader.php b/src/Builder/Internal/SpecLoader.php index c7a8a39..56f96c7 100644 --- a/src/Builder/Internal/SpecLoader.php +++ b/src/Builder/Internal/SpecLoader.php @@ -19,6 +19,7 @@ use function is_file; use function sprintf; +/** @internal */ final readonly class SpecLoader { public function __construct( diff --git a/src/Compiler/Internal/ArrayConstraints.php b/src/Compiler/Internal/ArrayConstraints.php index 98c4fc3..ef3dd98 100644 --- a/src/Compiler/Internal/ArrayConstraints.php +++ b/src/Compiler/Internal/ArrayConstraints.php @@ -16,6 +16,8 @@ * requires it (enum / const / uniqueItems). Items iteration (the * `foreach` + recursive constraint emission) is owned by the * orchestrator because it recurses through the full schema tree. + * + * @internal */ final readonly class ArrayConstraints { diff --git a/src/Compiler/Internal/EqualityHelpers.php b/src/Compiler/Internal/EqualityHelpers.php index 6eaad46..766e202 100644 --- a/src/Compiler/Internal/EqualityHelpers.php +++ b/src/Compiler/Internal/EqualityHelpers.php @@ -19,6 +19,8 @@ * object keys, bool distinct from int, and the IEEE 754 boundary * `9007199254740992` (2^53) above which mixed int/float comparisons are * rejected as unequal. + * + * @internal */ final readonly class EqualityHelpers { diff --git a/src/Compiler/Internal/FloatQuotientContext.php b/src/Compiler/Internal/FloatQuotientContext.php index 843f03d..2434097 100644 --- a/src/Compiler/Internal/FloatQuotientContext.php +++ b/src/Compiler/Internal/FloatQuotientContext.php @@ -7,6 +7,8 @@ /** * Carries the float-path inputs for ScalarConstraints::buildFloatQuotient * so the collaborator method signature stays at one parameter (§10 rule of three). + * + * @internal */ final readonly class FloatQuotientContext { diff --git a/src/Compiler/Internal/MultipleOfContext.php b/src/Compiler/Internal/MultipleOfContext.php index 64f9b1f..4fdc477 100644 --- a/src/Compiler/Internal/MultipleOfContext.php +++ b/src/Compiler/Internal/MultipleOfContext.php @@ -7,6 +7,8 @@ /** * Carries the integer-path inputs for ScalarConstraints::generateMultipleOf * so the collaborator method signature stays at one parameter (§10 rule of three). + * + * @internal */ final readonly class MultipleOfContext { diff --git a/src/Compiler/Internal/ObjectConstraints.php b/src/Compiler/Internal/ObjectConstraints.php index 513abd2..e1e8cd4 100644 --- a/src/Compiler/Internal/ObjectConstraints.php +++ b/src/Compiler/Internal/ObjectConstraints.php @@ -15,6 +15,8 @@ * the ValidatorCompiler. Property recursion and items iteration stay in * the orchestrator because they traverse the schema tree via the shared * recursive `generateConstraintsForSchema` entry point. + * + * @internal */ final readonly class ObjectConstraints { diff --git a/src/Compiler/Internal/PatternCheck.php b/src/Compiler/Internal/PatternCheck.php index d0754be..22d6fad 100644 --- a/src/Compiler/Internal/PatternCheck.php +++ b/src/Compiler/Internal/PatternCheck.php @@ -24,6 +24,8 @@ * execute, even when the validator throws; * - `preg_match === false` (PCRE compile error) is disambiguated from * `preg_match === 0` (no match) via distinct `RuntimeException` messages. + * + * @internal */ final readonly class PatternCheck { diff --git a/src/Compiler/Internal/ScalarConstraints.php b/src/Compiler/Internal/ScalarConstraints.php index dafdefc..50275e4 100644 --- a/src/Compiler/Internal/ScalarConstraints.php +++ b/src/Compiler/Internal/ScalarConstraints.php @@ -21,6 +21,8 @@ * collaborator holds no mutable state and is constructed fresh per * compile() call. It delegates UTF-16 length computation to Utf16Length * and pattern matching to PatternCheck. + * + * @internal */ final readonly class ScalarConstraints { diff --git a/src/Compiler/Internal/UnsupportedKeywordDetector.php b/src/Compiler/Internal/UnsupportedKeywordDetector.php index 6cf95b8..d529e0b 100644 --- a/src/Compiler/Internal/UnsupportedKeywordDetector.php +++ b/src/Compiler/Internal/UnsupportedKeywordDetector.php @@ -19,6 +19,8 @@ * UnsupportedKeywordException rather than silently emitting a validator * that ignores the keyword. Detection is recursive — unsupported * keywords inside nested `properties` or `items` are also reported. + * + * @internal */ final readonly class UnsupportedKeywordDetector { diff --git a/src/Compiler/Internal/Utf16Length.php b/src/Compiler/Internal/Utf16Length.php index 9ae17c3..2e12c7b 100644 --- a/src/Compiler/Internal/Utf16Length.php +++ b/src/Compiler/Internal/Utf16Length.php @@ -11,6 +11,8 @@ * minLength/maxLength validation. The compiled loop walks UTF-8 bytes once * and converts each code point to its UTF-16 length (1 or 2 code units for * code points outside the BMP). + * + * @internal */ final readonly class Utf16Length { From 46c670b300b60599806afe241b049592448e9f39 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 17:16:25 +1000 Subject: [PATCH 06/15] =?UTF-8?q?test:=20Add=20NestedValidationError=20reg?= =?UTF-8?q?ression=20test=20(B-TEST-1)=20(Task=2006=20=E2=80=94=20partitio?= =?UTF-8?q?n=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add regression test in DependentSchemasValidatorTest.php for NestedValidationError wrap path (previously 0% coverage). - Test scenario: NAN value + oneOf [minimum:0, maximum:100] triggers InvalidDataTypeException in both branches → OneOfValidator throws plain ValidationException with empty errors → PropertiesValidator preserves empty errors → DependentSchemasValidator catch ([] === $errors) wraps in NestedValidationError. - Coverage: NestedValidationError.php 0% → 100% (7/7 lines). - Anti-test verified: removing wrap branch (DependentSchemasValidator lines 91-99) causes test to fail ('actual size 0 matches expected size 1') — genuine regression protection. Refs: B-TEST-1 (MEDIUM test-coverage blocker). Verified: make tests 7137 OK (baseline + 1), make psalm 0 errors (99.6790%), make cs-fix 0 files. Note: second NestedValidationError instantiation site in ItemValidationExceptionTrait.php:70 remains uncovered (follow-up). --- .../DependentSchemasValidatorTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php index cba4426..cd5a750 100644 --- a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php @@ -8,6 +8,7 @@ use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\NestedValidationError; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\ValidatorPool; use Duyler\OpenApi\Validator\Format\BuiltinFormats; @@ -15,6 +16,8 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use const NAN; + #[CoversClass(DependentSchemasValidator::class)] class DependentSchemasValidatorTest extends TestCase { @@ -205,4 +208,39 @@ public function catch_invalid_data_type_in_nested_property(): void fclose($resource); } } + + #[Test] + public function nested_validator_throwing_plain_validation_exception_is_wrapped_in_nested_validation_error(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'value' => new Schema( + oneOf: [ + new Schema(minimum: 0), + new Schema(maximum: 100), + ], + ), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: [ + 'trigger' => $dependentSchema, + ], + ); + + try { + $this->validator->validate([ + 'trigger' => 'active', + 'value' => NAN, + ], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(NestedValidationError::class, $errors[0]); + } + } } From 53268eafd7d5b9896ef1dcb2dbee28eea93eb114 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 17:40:14 +1000 Subject: [PATCH 07/15] =?UTF-8?q?docs:=20Document=207=20racy=20caches=20+?= =?UTF-8?q?=20reset()=20prefork-only=20contract=20(B-CONC-2/3)=20(Task=200?= =?UTF-8?q?7=20=E2=80=94=20partition=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: add 7 racy cache classes to 'Unsafe classes and their contracts' table (PathRegexCache, RegexValidator, RefResolver, SchemaValidatorWithContext, EnumScalarCache, SchemaValidator, CompilationCache). Table now has 10 rows (3 existing @danger + 7 racy memoization). Add explanatory paragraph distinguishing correctness- critical (@danger NOT_THREAD_SAFE) from performance-memoization racy. - README.md: add reset() prefork-only paragraph in Long-Running Processes section; enumerate all 4 caches cleared (ValidatorPool, PathRegexCache, RefResolver, RegexValidator). - src/Builder/OpenApiValidatorInterface.php: update reset() PHPDoc — replace misleading 'Safe to call between requests in long-running processes' with prefork-only contract (mirror implementation PHPDoc). - src/Validator/OpenApiValidator.php: add reset() PHPDoc with precise prefork-only contract (safe when no concurrent validation in progress). Refs: B-CONC-2 + B-CONC-3 (MEDIUM concurrency blockers). Verified: make psalm 0 errors (99.6790%), make tests 7137 OK, make cs-fix 0 files. All 7 FQCN resolve to actual files; reset() body clears exactly the 4 caches enumerated in README. --- README.md | 28 +++++++++++++++++++++++ src/Builder/OpenApiValidatorInterface.php | 7 +++++- src/Validator/OpenApiValidator.php | 11 +++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a8df09..a1d4d77 100644 --- a/README.md +++ b/README.md @@ -1782,6 +1782,24 @@ per-class mitigation. | `Duyler\OpenApi\Validator\ValidatorPool` | Shared mutable `$cache`/`$order` and check-then-act sequence in `getOrCreate()` | Construct via `ValidatorPool::forCoroutineRuntime($lock, $maxSize)` with a `Swoole\Lock` (or any object exposing `lock()`/`unlock()`); never recurse into `getOrCreate()` from inside the factory closure | Swoole coroutines, FrankenPHP threaded workers | | `Duyler\OpenApi\Validator\LibxmlSecuredContext` | Process-global `libxml_use_internal_errors` and `libxml_set_external_entity_loader` captured/restored inside `run()` | Run XML body validation (`contentMediaType: application/xml`) in a prefork worker or delegate XML parsing to an isolated `Swoole\Process` worker; under coroutines the helper may either bypass XXE protection for one coroutine or disable the entity loader process-wide | Swoole coroutines, FrankenPHP threaded workers | | `Duyler\OpenApi\Validator\PregExecutor` | Process-global `pcre.backtrack_limit` and `pcre.recursion_limit` mutated via `ini_set` in `match()`/`matchAll()` | Prefer prefork workers; each coroutine should own its own `PregExecutor` instance (the default) and must not assume the ReDoS cap applies to a specific call when coroutines yield inside `preg_match` | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Request\PathRegexCache` | Mutable memoization of compiled path regex | Construct per-coroutine or per-worker; do not share across coroutines without external lock | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\RegexValidator` | In-process cache of compiled JSON Schema `pattern` regex | Per-coroutine/per-worker instance; external lock for shared use | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\RefResolver` | WeakMap-based `$ref` resolution cache | Per-coroutine/per-worker; never share across coroutines (resolved refs may point to stale schema) | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\SchemaValidatorWithContext` | Per-instance `ValidationContext` and dispatch cache | Per-coroutine/per-worker; reset() clears caches that other coroutines may be reading | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\SchemaValidator\EnumScalarCache` | Per-instance WeakMap-based enum result memoization | Per-coroutine/per-worker; WeakMap entries are not isolated across coroutines sharing one instance | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\SchemaValidator\SchemaValidator` | Legacy dispatcher with in-memory type dispatch table | Per-coroutine/per-worker; do not share legacy SchemaValidator across coroutines | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Compiler\CompilationCache` | PSR-6-backed compiled-validator cache; in-memory hit memoization | Per-coroutine/per-worker instance; PSR-6 backend is shared-safe but local memo is racy | Swoole coroutines, FrankenPHP threaded workers | + +The seven classes above are racy under shared mutable state (Swoole +coroutines, FrankenPHP threaded workers) because they keep in-memory +caches keyed by schema/data identity. They are NOT marked +`@danger NOT_THREAD_SAFE` in source code (unlike `ValidatorPool`, +`LibxmlSecuredContext`, and `PregExecutor`) because the racy state is +performance memoization, not correctness-critical. Cache miss +recomputes the correct result; cache hit from another coroutine +returns a correct value but may cause torn reads under concurrent +mutation. Construct one instance per coroutine/per worker to avoid +the race entirely. ##### O-004 — nested `getOrCreate()` deadlocks under `Swoole\Lock(SWOOLE_MUTEX)` @@ -1832,6 +1850,16 @@ requires per-coroutine validator construction. The prefork model (one request per worker process, no shared mutable state) is the safest option and requires no extra configuration. +The `OpenApiValidator::reset()` method is **prefork-only**. It clears +the validator's in-memory caches (`ValidatorPool`, `PathRegexCache`, +`RefResolver`, and `RegexValidator`) and is safe to call once at +worker startup before requests begin. Under Swoole coroutines or +FrankenPHP threaded workers, calling `reset()` while other coroutines +are mid-validation causes torn reads from a cleared cache — construct +a fresh validator per coroutine instead. The +`OpenApiValidatorInterface::reset()` declaration does not change, but +callers must enforce prefork-only usage themselves. + ```php // Build once at worker startup $validator = OpenApiValidatorBuilder::create() diff --git a/src/Builder/OpenApiValidatorInterface.php b/src/Builder/OpenApiValidatorInterface.php index a4d4be0..ccffe9c 100644 --- a/src/Builder/OpenApiValidatorInterface.php +++ b/src/Builder/OpenApiValidatorInterface.php @@ -142,7 +142,12 @@ public function resolveLinkWithContext(string $linkName, LinkContext $context): * Reset internal state for hot-reload scenarios. * * Clears validator pool cache and ref resolver cache. - * Safe to call between requests in long-running processes. + * + * Prefork-only contract: safe to call when no concurrent validation + * is in progress (always true in prefork models — PHP-FPM, + * RoadRunner, FrankenPHP non-threaded). Racy under Swoole coroutines + * or FrankenPHP threaded workers — construct a fresh validator per + * coroutine/per worker instead of calling reset() mid-flight. */ public function reset(): void; } diff --git a/src/Validator/OpenApiValidator.php b/src/Validator/OpenApiValidator.php index f291838..a83fcc1 100644 --- a/src/Validator/OpenApiValidator.php +++ b/src/Validator/OpenApiValidator.php @@ -88,6 +88,17 @@ public function getFormattedErrors(ValidationException $e): string return $this->dependencies->errorFormatter->formatMultiple($e->getErrors()); } + /** + * Reset the validator's in-memory caches and per-instance memoization. + * + * Prefork-only contract: safe to call when no concurrent validation + * is in progress (always true in prefork models — PHP-FPM, + * RoadRunner, FrankenPHP non-threaded). Racy under Swoole coroutines + * or FrankenPHP threaded workers — concurrent `validateRequest()` + * calls may read from a cache that is being cleared, causing torn + * reads or silent re-validation. Use per-coroutine/per-worker + * validator instances instead of reset(). + */ #[Override] public function reset(): void { From 869a28c9bd492e628122d57d42df77ff62562bc3 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 18:37:35 +1000 Subject: [PATCH 08/15] =?UTF-8?q?docs:=20Add=20Stability/BC=20policy=20sec?= =?UTF-8?q?tion=20to=20README=20(B-API-2)=20(Task=2008=20=E2=80=94=20parti?= =?UTF-8?q?tion=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ## Stability / Backward Compatibility section between Features and Installation: SemVer 2.0 adherence, BC criteria (3 rules), exclusions (*\Internal namespaces, @experimental, named constructor args), deprecation policy, patch/minor release commitments. - Schema constructor correctly described as 57-parameter (not 56). - CHANGELOG reference uses plain link (no broken GFM anchor). - Internal namespace enumeration uses *\Internal wildcard with 4 examples. Refs: B-API-2 (MEDIUM public-API stability blocker). Verified: psalm 0 errors, tests 7137 OK, cs-fix 0 files. --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/README.md b/README.md index a1d4d77..50607af 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,74 @@ OpenAPI 3.2 validator for PHP 8.4+ - **Schema Registry** - Manage multiple schema versions - **Validator Compilation** (experimental) - Generate optimized validator code for basic schemas (see Limitations) +## Stability / Backward Compatibility + +This package follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +The 1.x line is the long-term stable line; 2.0 will be the next breaking +release with no scheduled date. + +### What is covered by the 1.0 stability guarantee + +A symbol is part of the BC contract **if and only if** all of the +following are true: + +- It is in a non-`Internal` namespace (any top-level namespace component + that is NOT `Internal` — e.g. `Builder`, `Validator`, `Schema`, + `Compiler` top-level; `*\Internal` subnamespaces are excluded). +- It does not carry the `@internal` marker in its PHPDoc. +- It does not carry the `@experimental` marker in its PHPDoc + (currently only `Duyler\OpenApi\Compiler\ValidatorCompiler`). + +For symbols matching the criteria above, the following are locked +for the entire 1.x lifecycle: + +- Class, interface, trait, and enum existence (no removals, no renames). +- Method signatures (parameter names, types, defaults, order). +- Constructor parameter signatures (see the `Schema` exception below). +- Method behaviour for documented inputs (no silent semantic changes). +- Exception types thrown for documented error conditions. + +### What is NOT covered + +- **`*\Internal` namespaces** (e.g. `Builder\Internal`, `Compiler\Internal`, + `Validator\Internal`, `Schema\Model\Internal`) — these classes are private + implementation details and may change in any minor release. They are + additionally marked `@internal` so static analyzers + (psalm/internal_plugin, PHPStan bleeding-edge) flag user dependencies + on them. +- **`@experimental` symbols** — currently `ValidatorCompiler` and its + generated code shape. The compiler's public interface (method + signatures, supported keywords, codegen output format) may change + in any minor release (1.1, 1.2, ...) without notice. Pin the exact + version if you depend on it. +- **Constructor parameters as named arguments** — the `Schema` model + class has a 57-parameter constructor (locked at the structural + level: positional arguments are stable), but passing arguments by + name is **not** part of the BC contract because PHP allows parameter + rename to break named-argument callers. Use positional construction + or the builder for forward compatibility. +- **Protected methods on abstract classes** — these are extension + points but their signatures may change in minor releases if the + concrete subclass contract does not break. +- **Private state and trait internals** — implementation details. + +### Deprecation policy + +Symbols scheduled for removal in 2.0 are marked `@deprecated +in PHPDoc with a documented replacement. Deprecated symbols remain in +1.x without behavioural change; they are removed in the next major +release. Currently deprecated symbols are listed in the [CHANGELOG](CHANGELOG.md). + +### Patch releases (1.0.x) + +Patch releases contain bug fixes and security patches only. No new +features, no BC breaks, no deprecation additions. + +### Minor releases (1.x.0) + +Minor releases may add new features, deprecate existing symbols, or +expand supported PHP versions. No BC breaks against the contract above. + ## Installation ```bash From 92fceca92cec0e787abe852448789e2694d10543 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 18:44:19 +1000 Subject: [PATCH 09/15] =?UTF-8?q?docs:=20Draft=20[1.0.0]=20release=20notes?= =?UTF-8?q?=20in=20CHANGELOG=20(B-DOC-2)=20(Task=2009=20=E2=80=94=20partit?= =?UTF-8?q?ion=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ## [1.0.0] - YYYY-MM-DD section before ## [0.7.0] with Added, Changed, Deprecated, Security, and Acknowledgments subsections. - Add [1.0.0]: link reference in footer between [Unreleased] and [0.7.0]. - Update [Unreleased]: URL from compare/0.6.0...HEAD to compare/1.0.0...HEAD (1.0.0 is now the latest released version). - Date placeholder YYYY-MM-DD kept (filled at tag time). - No individual contributor names (company policy); link to GitHub contributors page instead. Refs: B-DOC-2 (MEDIUM documentation blocker). Verified: psalm 0 errors, tests 7137 OK, cs-fix 0 files. --- CHANGELOG.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd2582..a753e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ 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). +## [1.0.0] - YYYY-MM-DD + +First stable release. The 1.x line is the long-term stable line +following [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +See the [Stability / Backward Compatibility](README.md#stability--backward-compatibility) +section in the README for the full BC contract. + +### Added + +- Stable public API surface for the `OpenApiValidatorBuilder`, `OpenApiValidator`, + `OpenApiValidatorInterface`, `Schema`, and `OpenApiDocument` classes. +- `OpenApiValidatorInterface` extended with introspection accessors + (`getPool`, `isCoercion`, `isNullableAsType`, `getEmptyArrayStrategy`, + `getErrorFormatter`, `getCache`) — see `IntrospectableOpenApiValidatorInterface`. +- BC policy section in README documenting what is and is not covered by + the 1.0 stability guarantee. +- YAML billion-laughs defense: `MAX_ALIAS_DEPTH = 4` (lowered from 10), + `MAX_EXPANSION_BYTES = 5_000_000` post-parse size cap. +- Symfony YAML constraint tightened to `^7.4 || ^8.1` (LTS + current + only; EOL versions 7.0-7.3 and 8.0 are rejected). +- Streaming response validation for NDJSON, SSE, and JSON Text Sequences + with `withMaxStreamingRecords()` cap (default 100 000). +- `OpenApiValidator::reset()` documented as prefork-only (racy under + Swoole coroutines and FrankenPHP threaded workers). +- 7 racy memoization classes documented in README "Unsafe classes and + their contracts" table. +- 12 `Internal\` namespace classes marked `@internal` for static + analyzer enforcement. + +### Changed + +- `composer` constraint `symfony/yaml`: `^7.0 || ^8.0` → `^7.4 || ^8.1`. +- `YamlParser::MAX_ALIAS_DEPTH`: `10` → `4` (chain bomb DoS fix). +- CI Swoole job filter expanded from `SwooleSharedValidatorTest` to + `(SwooleSharedValidatorTest|ValidatorPoolTest)` — coroutine test + now under regression protection. + +### Deprecated + +The following symbols are marked `@deprecated` and scheduled for removal +in 2.0. They remain functional in 1.x with no behavioural change. + +- `OpenApiValidatorInterface::getFormattedErrors()` — use + `ErrorFormatterInterface::formatException()` instead. +- `OpenApiValidatorInterface::getType()` on validation errors — use + `keyword()` instead. +- `OpenApiValidatorBuilder::enableStrictCallbackRuntimeTemplate()` — + no-op since strict mode became the default (SEC-09). +- `OpenApiValidatorBuilder::enableNullableAsType()` — nullable + validation is now on by default. +- `SchemaValidator` (legacy stateless dispatcher) — use + `SchemaValidatorWithContext` for full annotation coverage. + +### Security + +- Fixed YAML chain billion-laughs DoS (CVSS 7.5, CWE-400 / CWE-770): + a 505-byte anchor-chain payload previously caused ~17 s CPU and + ~1.5 GB RAM per parse. With `MAX_ALIAS_DEPTH = 4` and the new + post-parse size cap, the same payload is rejected in <100 ms. + Reported by the 1.0 production readiness audit. +- Tightened Symfony YAML version constraint to exclude EOL versions + (7.0-7.3, 8.0) that no longer receive security patches. + +### Acknowledgments + +The 1.0 release was prepared with the help of a multi-agent +production readiness audit covering API stability, test coverage, +security, performance, documentation, concurrency, dependencies, +and tech debt. See `.ai/research/1.0-production-readiness-audit.md` +for the full audit report. Thanks to all [GitHub contributors](https://github.com/duyler/openapi/graphs/contributors). + ## [0.7.0] Preparation for the 1.0.0 stable release. This section tracks work that @@ -676,7 +747,8 @@ fail-closes on unresolvable callback expressions. ### Changed - Set `symfony/yaml` requirement to `^7.0`. -[Unreleased]: https://github.com/duyler/openapi/compare/0.6.0...HEAD +[Unreleased]: https://github.com/duyler/openapi/compare/1.0.0...HEAD +[1.0.0]: https://github.com/duyler/openapi/compare/0.7.0...1.0.0 [0.7.0]: https://github.com/duyler/openapi/compare/0.6.0...0.7.0 [0.6.0]: https://github.com/duyler/openapi/compare/0.5.0...0.6.0 [0.5.0]: https://github.com/duyler/openapi/compare/0.4.1...0.5.0 From 21c0bd49d485e2d36e42e8937a7c3db7425d75b9 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 18:51:07 +1000 Subject: [PATCH 10/15] =?UTF-8?q?docs:=20Add=20class-level=20PHPDoc=20to?= =?UTF-8?q?=2024=20public=20classes=20(B-DOC-3/4)=20(Task=2010=20=E2=80=94?= =?UTF-8?q?=20partition=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add multi-line class-level PHPDoc to OpenApiValidatorBuilder (entry point, terminal method) and OpenApiValidator (interface implementation, introspection accessors). - Add one-line English PHPDoc to 22 typed-error exception classes (TypeMismatchError through NotValidationError). - All PHPDoc on public API elements (§12 exception allows PHPDoc on public API in English). - 27 additional exception classes remain without class-level PHPDoc (spec provided text only for 22; follow-up for remaining classes). Refs: B-DOC-3 + B-DOC-4 (MEDIUM documentation blockers). Verified: psalm 0 errors, tests 7137 OK, cs-fix 0 files. --- src/Builder/OpenApiValidatorBuilder.php | 5 +++++ src/Validator/Exception/AdditionalPropertyError.php | 1 + src/Validator/Exception/ContainsMatchError.php | 1 + src/Validator/Exception/DuplicateItemsError.php | 1 + src/Validator/Exception/MaxContainsError.php | 1 + src/Validator/Exception/MaxItemsError.php | 1 + src/Validator/Exception/MaxLengthError.php | 1 + src/Validator/Exception/MaxPropertiesError.php | 1 + src/Validator/Exception/MaximumError.php | 1 + src/Validator/Exception/MinContainsError.php | 1 + src/Validator/Exception/MinItemsError.php | 1 + src/Validator/Exception/MinLengthError.php | 1 + src/Validator/Exception/MinPropertiesError.php | 1 + src/Validator/Exception/MinimumError.php | 1 + src/Validator/Exception/MultipleOfKeywordError.php | 1 + src/Validator/Exception/NotValidationError.php | 1 + src/Validator/Exception/OneOfError.php | 1 + src/Validator/Exception/PatternMismatchError.php | 1 + src/Validator/Exception/ReadOnlyPropertyError.php | 1 + src/Validator/Exception/RequiredError.php | 1 + src/Validator/Exception/TypeMismatchError.php | 1 + src/Validator/Exception/UnevaluatedPropertyError.php | 1 + src/Validator/Exception/WriteOnlyPropertyError.php | 1 + src/Validator/OpenApiValidator.php | 5 +++++ 24 files changed, 32 insertions(+) diff --git a/src/Builder/OpenApiValidatorBuilder.php b/src/Builder/OpenApiValidatorBuilder.php index 1914f20..e4705a6 100644 --- a/src/Builder/OpenApiValidatorBuilder.php +++ b/src/Builder/OpenApiValidatorBuilder.php @@ -50,6 +50,11 @@ use function realpath; use function sprintf; +/** + * Fluent immutable builder for {@see OpenApiValidator} instances. + * + * Entry point: {@see create()}. Terminal method: {@see build()}. + */ final readonly class OpenApiValidatorBuilder { private function __construct( diff --git a/src/Validator/Exception/AdditionalPropertyError.php b/src/Validator/Exception/AdditionalPropertyError.php index a1a73bb..80450f0 100644 --- a/src/Validator/Exception/AdditionalPropertyError.php +++ b/src/Validator/Exception/AdditionalPropertyError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an object contains a property not allowed by additionalProperties: false. */ final class AdditionalPropertyError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/ContainsMatchError.php b/src/Validator/Exception/ContainsMatchError.php index a841b2e..d0bd65f 100644 --- a/src/Validator/Exception/ContainsMatchError.php +++ b/src/Validator/Exception/ContainsMatchError.php @@ -4,6 +4,7 @@ namespace Duyler\OpenApi\Validator\Exception; +/** Thrown when an array has no items matching the schema's contains constraint. */ final class ContainsMatchError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/DuplicateItemsError.php b/src/Validator/Exception/DuplicateItemsError.php index 719040c..5357c10 100644 --- a/src/Validator/Exception/DuplicateItemsError.php +++ b/src/Validator/Exception/DuplicateItemsError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an array contains duplicate items despite uniqueItems: true. */ final class DuplicateItemsError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MaxContainsError.php b/src/Validator/Exception/MaxContainsError.php index 89a0c16..57c04fd 100644 --- a/src/Validator/Exception/MaxContainsError.php +++ b/src/Validator/Exception/MaxContainsError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when too many items match the schema's contains constraint (maxContains). */ final class MaxContainsError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MaxItemsError.php b/src/Validator/Exception/MaxItemsError.php index 84f0605..6320785 100644 --- a/src/Validator/Exception/MaxItemsError.php +++ b/src/Validator/Exception/MaxItemsError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an array exceeds the schema's maxItems constraint. */ final class MaxItemsError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MaxLengthError.php b/src/Validator/Exception/MaxLengthError.php index a755551..a7a2c8e 100644 --- a/src/Validator/Exception/MaxLengthError.php +++ b/src/Validator/Exception/MaxLengthError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a string value exceeds the schema's maxLength constraint. */ final class MaxLengthError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MaxPropertiesError.php b/src/Validator/Exception/MaxPropertiesError.php index e597927..30bee8b 100644 --- a/src/Validator/Exception/MaxPropertiesError.php +++ b/src/Validator/Exception/MaxPropertiesError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an object exceeds the schema's maxProperties constraint. */ final class MaxPropertiesError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MaximumError.php b/src/Validator/Exception/MaximumError.php index 11f848e..35d494d 100644 --- a/src/Validator/Exception/MaximumError.php +++ b/src/Validator/Exception/MaximumError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a numeric value exceeds the schema's maximum or exclusiveMaximum constraint. */ final class MaximumError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MinContainsError.php b/src/Validator/Exception/MinContainsError.php index 3a67f7e..845e04b 100644 --- a/src/Validator/Exception/MinContainsError.php +++ b/src/Validator/Exception/MinContainsError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when too few items match the schema's contains constraint (minContains). */ final class MinContainsError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MinItemsError.php b/src/Validator/Exception/MinItemsError.php index ec8a671..6b9eb38 100644 --- a/src/Validator/Exception/MinItemsError.php +++ b/src/Validator/Exception/MinItemsError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an array has fewer items than the schema's minItems constraint. */ final class MinItemsError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MinLengthError.php b/src/Validator/Exception/MinLengthError.php index c229bb8..07bf35f 100644 --- a/src/Validator/Exception/MinLengthError.php +++ b/src/Validator/Exception/MinLengthError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a string value is shorter than the schema's minLength constraint. */ final class MinLengthError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MinPropertiesError.php b/src/Validator/Exception/MinPropertiesError.php index daef16c..e867789 100644 --- a/src/Validator/Exception/MinPropertiesError.php +++ b/src/Validator/Exception/MinPropertiesError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an object has fewer properties than the schema's minProperties constraint. */ final class MinPropertiesError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MinimumError.php b/src/Validator/Exception/MinimumError.php index b6ae98b..7bf9ae8 100644 --- a/src/Validator/Exception/MinimumError.php +++ b/src/Validator/Exception/MinimumError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a numeric value is below the schema's minimum or exclusiveMinimum constraint. */ final class MinimumError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/MultipleOfKeywordError.php b/src/Validator/Exception/MultipleOfKeywordError.php index e267631..94307bd 100644 --- a/src/Validator/Exception/MultipleOfKeywordError.php +++ b/src/Validator/Exception/MultipleOfKeywordError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a numeric value is not a multiple of the schema's multipleOf constraint. */ final class MultipleOfKeywordError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/NotValidationError.php b/src/Validator/Exception/NotValidationError.php index 6299b2c..545886d 100644 --- a/src/Validator/Exception/NotValidationError.php +++ b/src/Validator/Exception/NotValidationError.php @@ -4,6 +4,7 @@ namespace Duyler\OpenApi\Validator\Exception; +/** Thrown when data matches the schema forbidden by the not keyword. */ final class NotValidationError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/OneOfError.php b/src/Validator/Exception/OneOfError.php index f25c2df..07ec9f4 100644 --- a/src/Validator/Exception/OneOfError.php +++ b/src/Validator/Exception/OneOfError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when data matches multiple oneOf schemas (must match exactly one). */ final class OneOfError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/PatternMismatchError.php b/src/Validator/Exception/PatternMismatchError.php index 73fad09..007a51b 100644 --- a/src/Validator/Exception/PatternMismatchError.php +++ b/src/Validator/Exception/PatternMismatchError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a string value fails the schema's pattern (regex) constraint. */ final class PatternMismatchError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/ReadOnlyPropertyError.php b/src/Validator/Exception/ReadOnlyPropertyError.php index c6aa7df..5262482 100644 --- a/src/Validator/Exception/ReadOnlyPropertyError.php +++ b/src/Validator/Exception/ReadOnlyPropertyError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a readOnly property is sent in a request payload. */ final class ReadOnlyPropertyError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/RequiredError.php b/src/Validator/Exception/RequiredError.php index 1646ad0..c9b818e 100644 --- a/src/Validator/Exception/RequiredError.php +++ b/src/Validator/Exception/RequiredError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a required property is missing from the data. */ final class RequiredError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/TypeMismatchError.php b/src/Validator/Exception/TypeMismatchError.php index 2e679c2..48c1eb5 100644 --- a/src/Validator/Exception/TypeMismatchError.php +++ b/src/Validator/Exception/TypeMismatchError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when the data type does not match the schema-declared type. */ final class TypeMismatchError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/UnevaluatedPropertyError.php b/src/Validator/Exception/UnevaluatedPropertyError.php index 6472563..4201f0e 100644 --- a/src/Validator/Exception/UnevaluatedPropertyError.php +++ b/src/Validator/Exception/UnevaluatedPropertyError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when an object contains a property not evaluated by any adjacent in-place applicator (unevaluatedProperties). */ final class UnevaluatedPropertyError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/Exception/WriteOnlyPropertyError.php b/src/Validator/Exception/WriteOnlyPropertyError.php index 74e5f9c..5e32a9d 100644 --- a/src/Validator/Exception/WriteOnlyPropertyError.php +++ b/src/Validator/Exception/WriteOnlyPropertyError.php @@ -6,6 +6,7 @@ use function sprintf; +/** Thrown when a writeOnly property is returned in a response payload. */ final class WriteOnlyPropertyError extends AbstractValidationError { public function __construct( diff --git a/src/Validator/OpenApiValidator.php b/src/Validator/OpenApiValidator.php index a83fcc1..5f932f8 100644 --- a/src/Validator/OpenApiValidator.php +++ b/src/Validator/OpenApiValidator.php @@ -20,6 +20,11 @@ use function sprintf; +/** + * Concrete {@see OpenApiValidatorInterface} implementation returned by + * {@see OpenApiValidatorBuilder::build()}. Exposes six read-only + * introspection accessors in addition to the interface contract. + */ final readonly class OpenApiValidator implements OpenApiValidatorInterface { public function __construct( From bb6f533ac8d1ef9a113967fa4efbe37279da5cbe Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 18:56:04 +1000 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20Add=20Memory=20Profile=20subsecti?= =?UTF-8?q?on=20to=20streaming=20docs=20(B-PERF-1)=20(Task=2011=20?= =?UTF-8?q?=E2=80=94=20partition=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ### Memory Profile subsection to README streaming validation section. - Documents: not constant-memory, ~47 MB peak at 100k records default cap, O(N) memory footprint, withMaxStreamingRecords() mitigation. - Placed between JSON Text Sequences and Error Handling in Streams. Refs: B-PERF-1 (MEDIUM performance documentation blocker). Verified: psalm 0 errors, tests 7137 OK, cs-fix 0 files. --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 50607af..a8ed4dc 100644 --- a/README.md +++ b/README.md @@ -2119,6 +2119,38 @@ $response = $factory->createResponse(200) $validator->validateResponse($response, $operation); ``` +### Memory Profile + +Streaming response validation is **not constant-memory**. Each decoded +record (NDJSON line, SSE event, JSON Text Sequences record) is fully +materialised in memory before schema validation runs, and the +validator retains the decoded records until the response stream is +exhausted or the `maxStreamingRecords` cap is reached. + +For typical JSON-line payloads (~470 bytes per record after decode), +the measured peak memory consumption is **~47 MB at the default cap +of 100 000 records**. The cost is linear in `record_count × +avg_record_size`; larger records or higher caps scale accordingly. + +Individual record schema validation is constant-time per record, but +the overall memory footprint of a single `validateResponse()` call +against a streaming body is **O(N)** in the number of decoded +records — there is no incremental GC between records. + +#### Mitigations + +- **`withMaxStreamingRecords(int $max)`** lowers the cap below the + default 100 000 when the validator runs in a memory-constrained + worker. Once the cap is reached, parsing stops and the validator + throws `TooManyRecordsException` (the response is rejected, never + partially accepted). +- **Filter at the source** — if you control the upstream service, + prefer paginating the response and validating each page separately + over a single long-running stream. +- **Use separate worker pools** for streaming endpoints with + different memory budgets; the `maxStreamingRecords` cap is + per-builder, not per-process. + ### Error Handling in Streams When a stream item fails to parse (invalid JSON), the parser logs a warning and yields `null` for that item. The validator skips `null` items. When a parsed item fails schema validation, a `ValidationException` is thrown immediately. From 65b485d1e5e2866cc1cfee924420871fb7ecb001 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 19:05:33 +1000 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20Mark=20Dto\ValidatorDependencies?= =?UTF-8?q?=20as=20@internal=20to=20resolve=20name=20collision=20(B-API-3)?= =?UTF-8?q?=20(Task=2013=20=E2=80=94=20partition=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @internal PHPDoc to Dto\ValidatorDependencies (internal wiring DTO). - Validation\ValidatorDependencies remains public (used by Builder). - SchemaValidator\ValidatorDependencies already @internal+@deprecated. - Add regression test verifying @internal markers on 2 of 3 classes. Refs: B-API-3 (MEDIUM API stability blocker). Verified: psalm 0 errors, tests 7141 OK, cs-fix 0 files. --- src/Validator/Dto/ValidatorDependencies.php | 3 + .../ValidatorDependenciesInternalMarkTest.php | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php diff --git a/src/Validator/Dto/ValidatorDependencies.php b/src/Validator/Dto/ValidatorDependencies.php index e8d9d13..f9760f8 100644 --- a/src/Validator/Dto/ValidatorDependencies.php +++ b/src/Validator/Dto/ValidatorDependencies.php @@ -20,7 +20,10 @@ use Duyler\OpenApi\Validator\ValidatorPool; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; +use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; +use Duyler\OpenApi\Validator\OpenApiValidator; +/** @internal wired by {@see OpenApiValidatorBuilder} and consumed by {@see OpenApiValidator}; the public 1.0 surface is {@see \Duyler\OpenApi\Validator\Validation\ValidatorDependencies}. */ final readonly class ValidatorDependencies { public function __construct( diff --git a/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php b/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php new file mode 100644 index 0000000..746f28b --- /dev/null +++ b/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php @@ -0,0 +1,79 @@ +getDocComment(); + + self::assertNotFalse($docComment, 'Dto\ValidatorDependencies must carry a PHPDoc block.'); + self::assertStringContainsString('@internal', $docComment); + } + + #[Test] + public function schema_validator_validator_dependencies_is_marked_internal(): void + { + $reflection = new ReflectionClass(\Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies::class); + $docComment = $reflection->getDocComment(); + + self::assertNotFalse($docComment, 'SchemaValidator\ValidatorDependencies must carry a PHPDoc block.'); + self::assertStringContainsString('@internal', $docComment); + } + + #[Test] + public function validation_validator_dependencies_is_not_marked_internal(): void + { + $reflection = new ReflectionClass(\Duyler\OpenApi\Validator\Validation\ValidatorDependencies::class); + $docComment = $reflection->getDocComment(); + + // The public 1.0 surface (consumed by OpenApiValidatorBuilder as + // `ValidationAssembler`) must not carry the @internal marker. A missing + // class-level PHPDoc block is the strongest form of compliance. + if (is_string($docComment)) { + self::assertStringNotContainsString( + '@internal', + $docComment, + 'Validation\ValidatorDependencies is the public 1.0 surface consumed by ' + . 'OpenApiValidatorBuilder; it must not carry the @internal marker.', + ); + } else { + self::assertFalse( + $reflection->isInternal(), + 'Validation\ValidatorDependencies must remain a userland (non-built-in) class.', + ); + } + } + + #[Test] + public function all_three_classes_remain_loadable_after_internal_mark(): void + { + // BC guard: the @internal marker must not break existing imports. + self::assertTrue(class_exists(ValidatorDependencies::class)); + self::assertTrue(class_exists(\Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies::class)); + self::assertTrue(class_exists(\Duyler\OpenApi\Validator\Validation\ValidatorDependencies::class)); + } +} From 858ba96cf5bf21730096a4337d5dab02a99081b9 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 19:13:30 +1000 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20Add=20IntrospectableOpenApiValida?= =?UTF-8?q?torInterface=20(B-TD-1)=20(Task=2014=20=E2=80=94=20partition=20?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create IntrospectableOpenApiValidatorInterface extending OpenApiValidatorInterface with 6 read-only accessors (getPool, isCoercion, isNullableAsType, getEmptyArrayStrategy, getErrorFormatter, getCache). - OpenApiValidator now implements IntrospectableOpenApiValidatorInterface. - OpenApiValidatorInterface unchanged (no BC break). - README updated: introspection accessors now part of interface contract. - Callers type-hinting OpenApiValidatorInterface are unaffected; callers needing introspection type-hint IntrospectableOpenApiValidatorInterface. Refs: B-TD-1 (MEDIUM tech debt blocker). Verified: psalm 0 errors, tests 7141 OK, cs-fix 0 files. --- README.md | 23 ++++++++----- ...ntrospectableOpenApiValidatorInterface.php | 34 +++++++++++++++++++ src/Validator/OpenApiValidator.php | 14 +++++--- 3 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 src/Builder/IntrospectableOpenApiValidatorInterface.php diff --git a/README.md b/README.md index a8ed4dc..8991bf4 100644 --- a/README.md +++ b/README.md @@ -175,12 +175,15 @@ name), `operationId` (nullable, populated when the spec declares one), and `'METHOD /path'` (e.g. `'GET /users/42'`), and `Operation::countPlaceholders(): int` returns the number of `{...}` placeholders in the template path. -The concrete `OpenApiValidator` instance returned by `build()` (which -implements `OpenApiValidatorInterface`) additionally exposes six -read-only introspection accessors that return the resolved builder -configuration. These are stable public API, intended for diagnostic -surfaces, middleware that needs to inspect the active validator, and -test fixtures: +The concrete `OpenApiValidator` instance returned by `build()` implements +both `OpenApiValidatorInterface` and `IntrospectableOpenApiValidatorInterface`. +The latter extends the former with six read-only introspection accessors +that return the resolved builder configuration. These are stable public +API, intended for diagnostic surfaces, middleware that needs to inspect +the active validator, and test fixtures. Callers that need these +accessors should type-hint `IntrospectableOpenApiValidatorInterface`; +callers that only need the standard validation surface can continue +to type-hint `OpenApiValidatorInterface`. | Method | Returns | Purpose | |--------|---------|---------| @@ -191,9 +194,11 @@ test fixtures: | `getErrorFormatter()` | `ErrorFormatterInterface` | The configured formatter | | `getCache()` | `?SchemaCache` | The configured PSR-6 cache, or `null` when caching is disabled | -The accessors are not part of `OpenApiValidatorInterface`; callers that -only type-hint the interface will not see them. Use the concrete class -(`OpenApiValidator`) when you need them. +The accessors are part of `IntrospectableOpenApiValidatorInterface` +(which extends `OpenApiValidatorInterface`); callers that need them +should type-hint `IntrospectableOpenApiValidatorInterface`. Callers +that only type-hint `OpenApiValidatorInterface` will not see them +(interface segregation — see the stability contract below). The `OpenApiDocument` returned by `getDocument()` is a `final readonly` value object implementing `JsonSerializable`. Its fields map to the diff --git a/src/Builder/IntrospectableOpenApiValidatorInterface.php b/src/Builder/IntrospectableOpenApiValidatorInterface.php new file mode 100644 index 0000000..31f6d6e --- /dev/null +++ b/src/Builder/IntrospectableOpenApiValidatorInterface.php @@ -0,0 +1,34 @@ +document; } + #[Override] public function getPool(): ValidatorPool { return $this->dependencies->pool; } + #[Override] public function isCoercion(): bool { return $this->configuration->coercion; } + #[Override] public function isNullableAsType(): bool { return $this->configuration->nullableAsType; } + #[Override] public function getEmptyArrayStrategy(): EmptyArrayStrategy { return $this->configuration->emptyArrayStrategy; } + #[Override] public function getErrorFormatter(): ErrorFormatterInterface { return $this->dependencies->errorFormatter; } + #[Override] public function getCache(): ?SchemaCache { return $this->dependencies->cache; From 6c587d1c649ec3f7558d902418a7dca72f80d56d Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 19:31:42 +1000 Subject: [PATCH 14/15] =?UTF-8?q?test:=20Add=20coverage=20tests=20for=206?= =?UTF-8?q?=20validators=20(B-TEST-3)=20(Task=2012=20=E2=80=94=20partition?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 36 new test methods across 6 validators targeting uncovered branches: error paths, boolean schema form, null context, composition. - DependentSchemasValidator (+6): InvalidFormatException rethrow, AbstractValidationError wrap, nullable property, nested anyOf. - ItemsValidatorWithContext (+5): boolean items true/false paths. - PrefixItemsValidator (+5): context=null creation, oneOf composition. - CallbackValidator (+7): resolution, HTTPS URL match/mismatch, curly brace template, strict mode. - AbstractSchemaValidator (+8): getDataPath, formatSchemaType variants. - ItemsValidator (+8): boolean items paths, InvalidFormatException, nested oneOf items. Tests: 7141 → 7181 (+40 new). Psalm: 0 errors. cs-fix: 0 files. Refs: B-TEST-3 (MEDIUM test coverage blocker). --- .../Schema/ItemsValidatorWithContextTest.php | 127 +++++++++++++ .../AbstractSchemaValidatorTest.php | 101 ++++++++++ .../AbstractSchemaValidatorTestStub.php | 57 ++++++ .../DependentSchemasValidatorTest.php | 145 ++++++++++++++ .../SchemaValidator/ItemsValidatorTest.php | 132 +++++++++++++ .../PrefixItemsValidatorTest.php | 104 ++++++++++ .../Validation/CallbackValidatorTest.php | 179 ++++++++++++++++++ 7 files changed, 845 insertions(+) create mode 100644 tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php create mode 100644 tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php diff --git a/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php b/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php index f6aec7d..0487d5a 100644 --- a/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php +++ b/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php @@ -775,4 +775,131 @@ public function validate_items_with_ref_continues_to_accept_non_null_values(): v $this->assertTrue(true); } + + #[Test] + public function validate_items_with_boolean_true_schema_evaluates_all_indices(): void + { + $schema = new Schema( + type: 'array', + items: true, + prefixItems: [new Schema(type: 'integer')], + ); + + $data = [42, 'anything', true, null]; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue($this->context->hasItemBeenEvaluated(1)); + $this->assertTrue($this->context->hasItemBeenEvaluated(2)); + $this->assertTrue($this->context->hasItemBeenEvaluated(3)); + $this->assertFalse($this->context->hasItemBeenEvaluated(0)); + } + + #[Test] + public function validate_items_with_boolean_false_schema_rejects_all_extra_items(): void + { + $schema = new Schema( + type: 'array', + items: false, + prefixItems: [new Schema(type: 'integer')], + ); + + $data = [42, 'rejected', 'also rejected']; + + try { + $this->validator->validateWithContext($data, $schema, $this->context); + $this->fail('Expected ValidationException for items: false'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + $this->assertCount(2, $errors); + $this->assertSame('Items rejected by items: false', $e->getMessage()); + } + } + + #[Test] + public function validate_items_with_boolean_true_schema_without_prefix_items_evaluates_all(): void + { + $schema = new Schema( + type: 'array', + items: true, + ); + + $data = [1, 2, 3]; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue($this->context->hasItemBeenEvaluated(0)); + $this->assertTrue($this->context->hasItemBeenEvaluated(1)); + $this->assertTrue($this->context->hasItemBeenEvaluated(2)); + } + + #[Test] + public function validate_items_with_boolean_false_schema_and_no_prefix_items_rejects_everything(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + try { + $this->validator->validateWithContext(['a', 'b'], $schema, $this->context); + $this->fail('Expected ValidationException for items: false without prefixItems'); + } catch (ValidationException $e) { + $this->assertSame('Items rejected by items: false', $e->getMessage()); + $this->assertCount(2, $e->getErrors()); + } + } + + #[Test] + public function validate_with_context_ignoring_discriminator_skips_discriminator_routing(): void + { + $catSchema = new Schema( + type: 'object', + title: 'Cat', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + required: ['petType'], + ); + + $petSchema = new Schema( + type: 'object', + discriminator: new Discriminator( + propertyName: 'petType', + mapping: ['cat' => '#/components/schemas/Cat'], + ), + oneOf: [new Schema(ref: '#/components/schemas/Cat')], + ); + + $schema = new Schema( + type: 'array', + items: new Schema(ref: '#/components/schemas/Pet'), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + ], + ), + ); + + $validator = new ItemsValidatorWithContext( + document: $document, + dependencies: new SchemaValidatorDependencies( + pool: $this->pool, + refResolver: $this->refResolver, + statelessValidators: $this->statelessValidators, + ), + ); + + $data = [['petType' => 'cat']]; + + $validator->validateWithContextIgnoringDiscriminator($data, $schema, $this->context); + + $this->assertTrue(true); + } } diff --git a/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php new file mode 100644 index 0000000..c8cdd6c --- /dev/null +++ b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php @@ -0,0 +1,101 @@ +pool = new ValidatorPool(); + } + + #[Test] + public function get_data_path_returns_root_slash_when_context_null(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('/', $validator->exposeGetDataPath(null)); + } + + #[Test] + public function get_data_path_returns_breadcrumb_path_when_context_supplied(): void + { + $validator = $this->buildConcreteValidator(); + $context = ValidationContext::create($this->pool); + $context->enterBreadcrumb('root'); + $context->enterBreadcrumb('child'); + + self::assertSame('/root/child', $validator->exposeGetDataPath($context)); + } + + #[Test] + public function format_schema_type_returns_default_when_type_is_null(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('scalar', $validator->exposeFormatSchemaType(null)); + } + + #[Test] + public function format_schema_type_returns_default_argument_when_supplied(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('object', $validator->exposeFormatSchemaType(null, 'object')); + } + + #[Test] + public function format_schema_type_joins_array_type_with_pipe(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('integer|string', $validator->exposeFormatSchemaType(['integer', 'string'])); + } + + #[Test] + public function format_schema_type_returns_string_type_as_is(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('integer', $validator->exposeFormatSchemaType('integer')); + } + + #[Test] + public function create_schema_validator_returns_root_schema_validator_instance(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertNotNull($validator->exposeCreateSchemaValidator()); + } + + #[Test] + public function pool_accessor_returns_injected_pool_instance(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame($this->pool, $validator->exposeDependencies()->pool); + } + + private function buildConcreteValidator(): AbstractSchemaValidatorTestStub + { + $dependencies = new ValidatorDependencies(pool: $this->pool, formatRegistry: BuiltinFormats::create()); + + return new AbstractSchemaValidatorTestStub($dependencies); + } +} diff --git a/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php new file mode 100644 index 0000000..72368da --- /dev/null +++ b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php @@ -0,0 +1,57 @@ +getDataPath($context); + } + + /** + * @param string|list|null $type + */ + public function exposeFormatSchemaType(array|string|null $type, string $default = 'scalar'): string + { + return $this->formatSchemaType($type, $default); + } + + public function exposeCreateSchemaValidator(): SchemaValidatorInterface + { + return $this->createSchemaValidator(); + } + + public function exposeDependencies(): ValidatorDependencies + { + return $this->dependencies; + } +} diff --git a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php index cd5a750..664a720 100644 --- a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php @@ -8,8 +8,11 @@ use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; +use Duyler\OpenApi\Validator\Exception\MinLengthError; use Duyler\OpenApi\Validator\Exception\NestedValidationError; use Duyler\OpenApi\Validator\Exception\ValidationException; +use Duyler\OpenApi\Validator\Error\ValidationContext; use Duyler\OpenApi\Validator\ValidatorPool; use Duyler\OpenApi\Validator\Format\BuiltinFormats; use PHPUnit\Framework\Attributes\CoversClass; @@ -243,4 +246,146 @@ public function nested_validator_throwing_plain_validation_exception_is_wrapped_ self::assertInstanceOf(NestedValidationError::class, $errors[0]); } } + + #[Test] + public function rethrow_invalid_format_exception_from_dependent_schema_without_wrapping(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'email' => new Schema(type: 'string', format: 'email'), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate([ + 'trigger' => 'active', + 'email' => 'not-an-email', + ], $schema); + } + + #[Test] + public function wrap_abstract_validation_error_from_dependent_schema_branch(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string', minLength: 5), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + try { + $this->validator->validate([ + 'trigger' => 'active', + 'name' => 'abc', + ], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(MinLengthError::class, $errors[0]); + self::assertStringContainsString('validation failed', $e->getMessage()); + } + } + + #[Test] + public function apply_dependent_schema_with_nullable_property_inside_accepts_null_value(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'optional' => new Schema(type: ['string', 'null']), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate([ + 'trigger' => 'active', + 'optional' => null, + ], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function apply_dependent_schema_declared_nullable_via_nullable_flag(): void + { + $dependentSchema = new Schema( + type: 'object', + nullable: true, + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate(['trigger' => 'active'], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function apply_dependent_schema_with_nested_composition_any_of_branch(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'value' => new Schema( + anyOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $this->validator->validate([ + 'trigger' => 'active', + 'value' => 'hello', + ], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validation_exception_with_existing_errors_passes_them_through_unchanged(): void + { + $dependentSchema = new Schema( + type: 'object', + required: ['missing1', 'missing2'], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + try { + $this->validator->validate(['trigger' => 'active'], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + self::assertStringContainsString('validation failed', $e->getMessage()); + self::assertNotEmpty($e->getErrors()); + } + } } diff --git a/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php b/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php index b85939d..1a91969 100644 --- a/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php @@ -8,8 +8,10 @@ use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Duyler\OpenApi\Validator\Exception\MaximumError; use Duyler\OpenApi\Validator\Exception\MinLengthError; +use Duyler\OpenApi\Validator\Exception\TypeMismatchError; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\ValidatorPool; use PHPUnit\Framework\Attributes\CoversClass; @@ -258,4 +260,134 @@ public function validate_items_with_context(): void $this->expectNotToPerformAssertions(); } + + #[Test] + public function validate_items_with_boolean_true_marks_items_evaluated_with_context(): void + { + $schema = new Schema( + type: 'array', + items: true, + prefixItems: [new Schema(type: 'integer')], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + $this->validator->validate([42, 'anything', true], $schema, $context); + + self::assertTrue($context->hasItemBeenEvaluated(1)); + self::assertTrue($context->hasItemBeenEvaluated(2)); + self::assertFalse($context->hasItemBeenEvaluated(0)); + } + + #[Test] + public function validate_items_with_boolean_true_without_context_does_not_track_evaluation(): void + { + $schema = new Schema( + type: 'array', + items: true, + ); + + $this->validator->validate([1, 2, 3], $schema, null); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_items_with_boolean_false_throws_type_mismatch_for_each_extra_item(): void + { + $schema = new Schema( + type: 'array', + items: false, + prefixItems: [new Schema(type: 'integer')], + ); + + $caught = null; + + try { + $this->validator->validate([42, 'rejected', 'also rejected'], $schema); + self::fail('Expected ValidationException'); + } catch (ValidationException $e) { + $caught = $e; + } + + $errors = $caught->getErrors(); + + self::assertCount(2, $errors); + self::assertInstanceOf(TypeMismatchError::class, $errors[0]); + self::assertInstanceOf(TypeMismatchError::class, $errors[1]); + self::assertSame('Items rejected by items: false', $caught->getMessage()); + } + + #[Test] + public function validate_items_with_boolean_false_and_no_prefix_items_rejects_everything(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Items rejected by items: false'); + + $this->validator->validate(['a', 'b', 'c'], $schema); + } + + #[Test] + public function validate_items_with_boolean_false_and_empty_data_passes(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + $this->validator->validate([], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function rethrow_invalid_format_exception_from_items_validation_without_wrapping(): void + { + $itemSchema = new Schema(type: 'string', format: 'email'); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate(['not-an-email'], $schema); + } + + #[Test] + public function validate_items_with_nested_one_of_composition_per_item(): void + { + $itemSchema = new Schema( + oneOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->validator->validate([1, 'two', 3, 'four'], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_items_legacy_invocation_without_context_creates_internal_context(): void + { + $itemSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->validator->validate(['hello', 'world'], $schema, null); + + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php b/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php index 3da830c..42b7567 100644 --- a/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Test\Unit\Validator\SchemaValidator; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Duyler\OpenApi\Validator\Exception\TypeMismatchError; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\Error\ValidationContext; @@ -464,4 +465,107 @@ public function throw_validation_exception_for_prefix_item_validation_failed(): $this->validator->validate(['string_value', 42], $schema); } + + #[Test] + public function rethrow_invalid_format_exception_from_prefix_item_without_wrapping(): void + { + $prefixSchema = new Schema(type: 'string', format: 'email'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate(['not-an-email'], $schema); + } + + #[Test] + public function validate_prefix_item_creates_context_when_none_supplied(): void + { + $prefixSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->validator->validate(['hello'], $schema, null); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_with_composition_one_of_branch_resolves_correctly(): void + { + $prefixSchema = new Schema( + oneOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + $succeeded = false; + + try { + $this->validator->validate([42], $schema, $context); + $this->validator->validate(['hello'], $schema, $context); + $succeeded = true; + } catch (ValidationException $e) { + self::fail(sprintf('Expected validation to pass, got: %s', $e->getMessage())); + } + + self::assertTrue($succeeded); + } + + #[Test] + public function validate_prefix_item_with_nullable_type_union_accepts_null_at_position(): void + { + $prefixSchema = new Schema(type: ['string', 'null']); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema, new Schema(type: 'integer')], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate([null, 42], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_min_items_above_prefix_count_validates_only_available_positions(): void + { + $prefixSchema1 = new Schema(type: 'string'); + $prefixSchema2 = new Schema(type: 'integer'); + $prefixSchema3 = new Schema(type: 'boolean'); + $schema = new Schema( + type: 'array', + minItems: 5, + prefixItems: [$prefixSchema1, $prefixSchema2, $prefixSchema3], + ); + + $this->validator->validate(['hello', 42, true], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_without_trailing_items_keyword_allows_extra_untyped_items(): void + { + $prefixSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->validator->validate(['hello', 42, true, ['nested'], null], $schema); + + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Unit/Validator/Validation/CallbackValidatorTest.php b/tests/Unit/Validator/Validation/CallbackValidatorTest.php index 1380997..b594c22 100644 --- a/tests/Unit/Validator/Validation/CallbackValidatorTest.php +++ b/tests/Unit/Validator/Validation/CallbackValidatorTest.php @@ -17,6 +17,7 @@ use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Validator\Callback\Exception\UnknownCallbackException; use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; +use Duyler\OpenApi\Validator\Exception\RefResolutionException; use Duyler\OpenApi\Validator\Exception\UnresolvableCallbackPathException; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\Format\FormatRegistry; @@ -267,6 +268,184 @@ public function validate_propagates_unknown_callback_exception_from_inner_valida $validator->validate($request, 'missingCallback'); } + #[Test] + public function validate_resolves_callback_path_item_via_components_path_items_ref(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $resolvedPathItem = new PathItem(post: $callbackOperation); + $refPathItem = new PathItem(ref: '#/components/pathItems/CallbackRef'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + pathItems: ['CallbackRef' => $resolvedPathItem], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_throws_ref_resolution_exception_for_unsupported_ref_prefix(): void + { + $refPathItem = new PathItem(ref: '#/components/schemas/NotAPathItem'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $this->expectException(RefResolutionException::class); + $this->expectExceptionMessage('Unsupported callback $ref'); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_throws_ref_resolution_exception_when_path_item_ref_target_missing(): void + { + $refPathItem = new PathItem(ref: '#/components/pathItems/Missing'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + pathItems: ['OtherRef' => new PathItem(post: new SchemaOperation(operationId: 'other'))], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $this->expectException(RefResolutionException::class); + $this->expectExceptionMessage('not found in components.pathItems'); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_matches_https_callback_url_by_path_component(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['https://api.example.com/cb/https-callback' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/https-callback'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_rejects_https_callback_url_with_mismatched_path(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['https://api.example.com/cb/expected' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/different'); + + $this->expectException(UnknownCallbackException::class); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_matches_callback_with_curly_brace_path_template(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/{id}' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/42'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_strict_mode_rejects_runtime_template_with_exception(): void + { + $context = $this->buildDependencies($this->buildDocumentWithCallback()); + $validator = new CallbackValidator($context, securityValidation: false, strictCallbackRuntimeTemplate: true); + + $request = $this->psrFactory->createServerRequest('POST', '/attacker-url'); + + $this->expectException(UnresolvableCallbackPathException::class); + + $validator->validate($request, 'myCallback'); + } + private function buildDocumentWithCallback(): OpenApiDocument { $callbackOperation = new SchemaOperation(operationId: 'myCallback'); From 8718cbcdc7d021231870f3bfa4515517af73e412 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Tue, 28 Jul 2026 22:48:56 +1000 Subject: [PATCH 15/15] fix: Rename 'AI-slop removal pass' to 'Dead-code removal pass' in CHANGELOG [0.5.0] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a753e76..a86eebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,7 +136,7 @@ internal-only unless explicitly marked as public API. `LibxmlSecuredContext` shrunk as a side effect; `TypeFormatter` deleted (consolidated into its single remaining caller). - §11 silent catches now emit PSR-3 log entries at the boundary. -- **AI-slop removal pass** — dead `UriScheme` enum and `AnyOfError` +- **Dead-code removal pass** — dead `UriScheme` enum and `AnyOfError` exception class deleted; `TypeCoercer` 4-predicate OR replaced with `is_scalar()`; `JsonEquals` and `EnumScalarCache` boolean expressions extracted into `isNumeric()` / `isScalarOrNull()` helpers; nested