From 71f49fa555bd08737346402316cfc2d92c2818e8 Mon Sep 17 00:00:00 2001 From: Woody Gilk Date: Tue, 11 Aug 2026 07:54:24 -0500 Subject: [PATCH] fix: Resolve `$ref` in `requestBody` instead of dropping it (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComponentTreeBuilder::buildRequestBody()` read only `description`, `content` and `required`, so a `requestBody` that was a Reference Object parsed to an empty `RequestBody` with the pointer discarded. `RequestBodyValidatorWithContext::validate()` then returned at its `null === $requestBody->content` early exit, because the `required: true` that would have forced a `MissingRequestBodyException` had been dropped along with the `$ref`. The result was fail-open: every request body behind a `$ref` went completely unvalidated. Malformed payloads passed, `required` was not enforced, and unsupported media types were accepted — with no exception and no warning. Inlining the same body validated correctly, so the breakage was invisible from the spec alone. Reference support is now wired through the five layers that `Parameter` and `Response` already used: - `RequestBody` gains `ref`/`refSummary`/`refDescription` and serializes back to a Reference Object, matching `Response`. - `ComponentTreeBuilder::buildRequestBody()` branches on `$ref`. - `DocumentNavigator` accepts `#/components/requestBodies/*` targets; `RefCache` widens to match. - `RefResolverInterface` gains `resolveRequestBody()` and `resolveRequestBodyWithOverride()`. - `RequestBodyValidatorWithContext::validate()` dereferences before use. Chained and dangling references behave as they do for responses: chains follow through, and an unresolvable or wrongly-typed pointer throws `UnresolvableRefException` rather than failing open. Per OAS 3.1+, a `description` sibling of `$ref` overrides the referenced description. Webhooks and callbacks are fixed by the same change, since both validate through `RequestValidator`. Inline request bodies take an unchanged path — `resolveRequestBodyWithOverride()` returns the same instance when `ref` is null. Closes #56 --- CHANGELOG.md | 24 ++ src/Schema/Model/RequestBody.php | 17 + .../Parser/Internal/ComponentTreeBuilder.php | 8 + .../RequestBodyValidatorWithContext.php | 5 + .../Schema/Internal/DocumentNavigator.php | 16 +- src/Validator/Schema/RefCache.php | 3 +- src/Validator/Schema/RefResolver.php | 32 ++ src/Validator/Schema/RefResolverInterface.php | 24 ++ .../Functional/Request/RequestBodyRefTest.php | 349 ++++++++++++++++++ .../Schema/Parser/ReferenceOverrideTest.php | 37 ++ .../Schema/RefResolverRequestBodyTest.php | 293 +++++++++++++++ 11 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 tests/Functional/Request/RequestBodyRefTest.php create mode 100644 tests/Unit/Validator/Schema/RefResolverRequestBodyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e730..14c70980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- A `requestBody` that is a Reference Object is now resolved instead of + silently discarded, so the referenced body is actually validated. The + parser dropped `$ref` — it read only `description`, `content` and + `required` — which produced an empty `RequestBody`, and + `RequestBodyValidatorWithContext` then returned at its + `null === $requestBody->content` early exit. Every request body behind a + `$ref` was therefore unvalidated: malformed payloads passed and + `required: true` was not enforced, with no exception or warning. Reference + support is now wired through all five layers that `Parameter` and + `Response` already used — `RequestBody` gains `ref`/`refSummary`/ + `refDescription`, `ComponentTreeBuilder::buildRequestBody()` branches on + `$ref`, `DocumentNavigator` accepts `#/components/requestBodies/*` targets, + and `RefResolverInterface` gains `resolveRequestBody()` and + `resolveRequestBodyWithOverride()`. Chained and dangling references behave + as they do for responses: chains follow through, and an unresolvable or + wrongly-typed pointer throws `UnresolvableRefException` rather than + failing open. Webhooks and callbacks are covered too, since both validate + through `RequestValidator`. Per OAS 3.1+, a `description` sibling of + `$ref` overrides the referenced description (#56). + ## [0.7.0] Preparation for the 1.0.0 stable release. This section tracks work that diff --git a/src/Schema/Model/RequestBody.php b/src/Schema/Model/RequestBody.php index 73d03c7f..e4196730 100644 --- a/src/Schema/Model/RequestBody.php +++ b/src/Schema/Model/RequestBody.php @@ -10,6 +10,9 @@ final readonly class RequestBody implements JsonSerializable { public function __construct( + public ?string $ref = null, + public ?string $refSummary = null, + public ?string $refDescription = null, public ?string $description = null, public ?Content $content = null, public bool $required = false, @@ -18,6 +21,20 @@ public function __construct( #[Override] public function jsonSerialize(): array { + if (null !== $this->ref) { + $data = ['$ref' => $this->ref]; + + if (null !== $this->refSummary) { + $data['summary'] = $this->refSummary; + } + + if (null !== $this->refDescription) { + $data['description'] = $this->refDescription; + } + + return $data; + } + $data = []; if (null !== $this->description) { diff --git a/src/Schema/Parser/Internal/ComponentTreeBuilder.php b/src/Schema/Parser/Internal/ComponentTreeBuilder.php index b82b610c..3d378e00 100644 --- a/src/Schema/Parser/Internal/ComponentTreeBuilder.php +++ b/src/Schema/Parser/Internal/ComponentTreeBuilder.php @@ -30,6 +30,14 @@ public function __construct(private OpenApiBuildContext $context) {} public function buildRequestBody(array $data): RequestBody { + if (isset($data['$ref'])) { + return new RequestBody( + ref: TypeHelper::asString($data['$ref']), + refSummary: TypeHelper::asStringOrNull($data['summary'] ?? null), + refDescription: TypeHelper::asStringOrNull($data['description'] ?? null), + ); + } + return new RequestBody( description: TypeHelper::asStringOrNull($data['description'] ?? null), content: $this->nullable($data, 'content', $this->buildContent(...)), diff --git a/src/Validator/Request/RequestBodyValidatorWithContext.php b/src/Validator/Request/RequestBodyValidatorWithContext.php index a2b7ee09..201cb3a3 100644 --- a/src/Validator/Request/RequestBodyValidatorWithContext.php +++ b/src/Validator/Request/RequestBodyValidatorWithContext.php @@ -55,6 +55,11 @@ public function validate( return; } + $requestBody = $this->dependencies->refResolver->resolveRequestBodyWithOverride( + $requestBody, + $this->document, + ); + if ($requestBody->required && '' === trim($body)) { throw new MissingRequestBodyException(); } diff --git a/src/Validator/Schema/Internal/DocumentNavigator.php b/src/Validator/Schema/Internal/DocumentNavigator.php index 8474187f..d175f120 100644 --- a/src/Validator/Schema/Internal/DocumentNavigator.php +++ b/src/Validator/Schema/Internal/DocumentNavigator.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Validator\Schema\Internal; use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; @@ -44,7 +45,7 @@ public function __construct( * @throws SchemaDepthExceededException * @throws UnresolvableRefException * - * @return array{Schema|Parameter|Response, array} + * @return array{Schema|Parameter|RequestBody|Response, array} */ public function resolveRef( string $ref, @@ -92,7 +93,7 @@ public function navigate( array $parts, int $depth = 0, int $maxDepth = ValidationContext::MAX_DEPTH, - ): Schema|Parameter|Response { + ): Schema|Parameter|RequestBody|Response { $count = count($parts); for ($i = 0; $i < $count; ++$i) { @@ -107,6 +108,7 @@ public function navigate( if ( $current instanceof Schema || $current instanceof Parameter + || $current instanceof RequestBody || $current instanceof Response ) { return $current; @@ -114,7 +116,7 @@ public function navigate( throw new UnresolvableRefException( '', - 'Target is not a Schema, Parameter, or Response', + 'Target is not a Schema, Parameter, RequestBody, or Response', ); } @@ -166,7 +168,7 @@ private function assertNotCircular(string $ref, array $visited): void } /** @param array $parts */ - private function navigateThrowing(string $ref, OpenApiDocument $document, array $parts): Schema|Parameter|Response + private function navigateThrowing(string $ref, OpenApiDocument $document, array $parts): Schema|Parameter|RequestBody|Response { try { return $this->navigate($document, $parts); @@ -176,7 +178,7 @@ private function navigateThrowing(string $ref, OpenApiDocument $document, array } /** @param WeakMap $cache */ - private function lookupCached(OpenApiDocument $document, string $ref, WeakMap $cache): Schema|Parameter|Response|null + private function lookupCached(OpenApiDocument $document, string $ref, WeakMap $cache): Schema|Parameter|RequestBody|Response|null { if (false === isset($cache[$document])) { return null; @@ -189,7 +191,7 @@ private function lookupCached(OpenApiDocument $document, string $ref, WeakMap $c } /** @param WeakMap $cache */ - private function storeCached(OpenApiDocument $document, string $ref, Schema|Parameter|Response $result, WeakMap $cache): void + private function storeCached(OpenApiDocument $document, string $ref, Schema|Parameter|RequestBody|Response $result, WeakMap $cache): void { /** @var RefCache $refCache */ $refCache = $cache[$document] ?? new RefCache(); @@ -200,7 +202,7 @@ private function storeCached(OpenApiDocument $document, string $ref, Schema|Para /** * @param array $visited * - * @return array{Schema|Parameter|Response, array} + * @return array{Schema|Parameter|RequestBody|Response, array} */ private function resolveExternalRef(string $ref, array $visited): array { diff --git a/src/Validator/Schema/RefCache.php b/src/Validator/Schema/RefCache.php index 645b6f96..c412797f 100644 --- a/src/Validator/Schema/RefCache.php +++ b/src/Validator/Schema/RefCache.php @@ -5,12 +5,13 @@ namespace Duyler\OpenApi\Validator\Schema; use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; /** @internal */ final class RefCache { - /** @var array */ + /** @var array */ public array $map = []; } diff --git a/src/Validator/Schema/RefResolver.php b/src/Validator/Schema/RefResolver.php index d1b1c5b4..0bd30e02 100644 --- a/src/Validator/Schema/RefResolver.php +++ b/src/Validator/Schema/RefResolver.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Validator\Schema; use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; @@ -124,6 +125,17 @@ public function resolveParameter(string $ref, OpenApiDocument $document, int $de return $result; } + #[Override] + public function resolveRequestBody(string $ref, OpenApiDocument $document, int $depth = 0): RequestBody + { + [$result,] = $this->navigator->resolveRef($ref, $document, [], $this->cache, $depth); + if (false === $result instanceof RequestBody) { + throw new UnresolvableRefException($ref, 'Expected RequestBody but got ' . $result::class); + } + + return $result; + } + #[Override] public function resolveResponse(string $ref, OpenApiDocument $document, int $depth = 0): Response { @@ -224,6 +236,26 @@ public function resolveParameterWithOverride( ); } + #[Override] + public function resolveRequestBodyWithOverride( + RequestBody $requestBody, + OpenApiDocument $document, + ): RequestBody { + if (null === $requestBody->ref) { + return $requestBody; + } + $resolved = $this->resolveRequestBody($requestBody->ref, $document); + + return new RequestBody( + ref: null, + refSummary: null, + refDescription: null, + description: $requestBody->refDescription ?? $resolved->description, + content: $resolved->content, + required: $resolved->required, + ); + } + #[Override] public function resolveResponseWithOverride( Response $response, diff --git a/src/Validator/Schema/RefResolverInterface.php b/src/Validator/Schema/RefResolverInterface.php index 76afcdd5..4933f603 100644 --- a/src/Validator/Schema/RefResolverInterface.php +++ b/src/Validator/Schema/RefResolverInterface.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Validator\Exception\RefResolutionException; use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; @@ -33,6 +34,18 @@ public function resolveParameter( int $depth = 0, ): Parameter; + /** + * @param string $ref JSON Pointer reference (e.g., '#/components/requestBodies/UserBody') + * @param int $depth Current recursion depth + * @throws Exception\UnresolvableRefException + * @throws SchemaDepthExceededException + */ + public function resolveRequestBody( + string $ref, + OpenApiDocument $document, + int $depth = 0, + ): RequestBody; + /** * @param string $ref JSON Pointer reference (e.g., '#/components/responses/SuccessResponse') * @param int $depth Current recursion depth @@ -113,6 +126,17 @@ public function resolveParameterWithOverride( OpenApiDocument $document, ): Parameter; + /** + * Resolve request body reference with summary/description override + * + * @param RequestBody $requestBody Request body with potential $ref and override values + * @throws Exception\UnresolvableRefException + */ + public function resolveRequestBodyWithOverride( + RequestBody $requestBody, + OpenApiDocument $document, + ): RequestBody; + /** * Resolve response reference with summary/description override * diff --git a/tests/Functional/Request/RequestBodyRefTest.php b/tests/Functional/Request/RequestBodyRefTest.php new file mode 100644 index 00000000..657f7634 --- /dev/null +++ b/tests/Functional/Request/RequestBodyRefTest.php @@ -0,0 +1,349 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function referenced_request_body_retains_the_pointer_after_parsing(): void + { + $document = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build() + ->getDocument(); + + $requestBody = $document->paths->paths['/data']->post?->requestBody; + + self::assertNotNull($requestBody); + self::assertSame('#/components/requestBodies/UserBody', $requestBody->ref); + } + + #[Test] + public function valid_body_against_referenced_request_body_passes(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":"John Doe"}')); + + $operation = $validator->validateRequest($request); + + self::assertSame('POST', $operation->method); + self::assertSame('/data', $operation->path); + } + + #[Test] + public function body_violating_referenced_schema_is_rejected(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":12345}')); + + $this->expectException(ValidationException::class); + $validator->validateRequest($request); + } + + #[Test] + public function body_missing_property_required_by_referenced_schema_is_rejected(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{}')); + + $this->expectException(ValidationException::class); + $validator->validateRequest($request); + } + + #[Test] + public function required_flag_from_referenced_request_body_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('')); + + $this->expectException(MissingRequestBodyException::class); + $validator->validateRequest($request); + } + + #[Test] + public function media_type_from_referenced_request_body_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'text/plain') + ->withBody($this->psrFactory->createStream('John Doe')); + + $this->expectException(UnsupportedMediaTypeException::class); + $validator->validateRequest($request); + } + + #[Test] + public function chained_request_body_reference_is_resolved(): void + { + $spec = <<<'YAML' +openapi: 3.1.0 +info: + title: Chained Request Body Ref API + version: 1.0.0 +paths: + /data: + post: + requestBody: + $ref: '#/components/requestBodies/Alias' + responses: + '201': + description: Created +components: + requestBodies: + Alias: + $ref: '#/components/requestBodies/UserBody' + UserBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string +YAML; + + $validator = OpenApiValidatorBuilder::create()->fromYamlString($spec)->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":12345}')); + + $this->expectException(ValidationException::class); + $validator->validateRequest($request); + } + + #[Test] + public function unresolvable_request_body_reference_throws(): void + { + $spec = <<<'YAML' +openapi: 3.1.0 +info: + title: Dangling Request Body Ref API + version: 1.0.0 +paths: + /data: + post: + requestBody: + $ref: '#/components/requestBodies/NoSuchBody' + responses: + '201': + description: Created +components: + requestBodies: + UserBody: + required: true + content: + application/json: + schema: + type: object +YAML; + + $validator = OpenApiValidatorBuilder::create()->fromYamlString($spec)->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":"John Doe"}')); + + $this->expectException(UnresolvableRefException::class); + $validator->validateRequest($request); + } + + #[Test] + public function request_body_reference_pointing_at_a_schema_throws(): void + { + $spec = <<<'YAML' +openapi: 3.1.0 +info: + title: Mistyped Request Body Ref API + version: 1.0.0 +paths: + /data: + post: + requestBody: + $ref: '#/components/schemas/User' + responses: + '201': + description: Created +components: + schemas: + User: + type: object +YAML; + + $validator = OpenApiValidatorBuilder::create()->fromYamlString($spec)->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/data') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":"John Doe"}')); + + $this->expectException(UnresolvableRefException::class); + $validator->validateRequest($request); + } + + #[Test] + public function sibling_description_overrides_the_referenced_description(): void + { + $spec = <<<'YAML' +openapi: 3.1.0 +info: + title: Request Body Ref Override API + version: 1.0.0 +paths: + /data: + post: + requestBody: + $ref: '#/components/requestBodies/UserBody' + description: Overridden at the call site + responses: + '201': + description: Created +components: + requestBodies: + UserBody: + description: Declared in components + required: true + content: + application/json: + schema: + type: object +YAML; + + $document = OpenApiValidatorBuilder::create()->fromYamlString($spec)->build()->getDocument(); + $requestBody = $document->paths->paths['/data']->post?->requestBody; + + self::assertNotNull($requestBody); + self::assertSame('Overridden at the call site', $requestBody->refDescription); + self::assertNull($requestBody->description); + } + + #[Test] + public function webhook_with_a_referenced_request_body_is_validated(): void + { + $spec = <<<'YAML' +openapi: 3.1.0 +info: + title: Webhook Request Body Ref API + version: 1.0.0 +webhooks: + userCreated: + post: + requestBody: + $ref: '#/components/requestBodies/UserBody' + responses: + '200': + description: OK +components: + requestBodies: + UserBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string +YAML; + + $validator = OpenApiValidatorBuilder::create()->fromYamlString($spec)->build(); + + $request = $this->psrFactory->createServerRequest('POST', '/hooks/user-created') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream('{"name":12345}')); + + $this->expectException(ValidationException::class); + $validator->validateWebhook($request, 'userCreated'); + } + + #[Test] + public function referenced_request_body_serializes_back_to_a_reference_object(): void + { + $document = OpenApiValidatorBuilder::create() + ->fromYamlString(self::REF_BODY_SPEC) + ->build() + ->getDocument(); + + $requestBody = $document->paths->paths['/data']->post?->requestBody; + + self::assertNotNull($requestBody); + self::assertSame( + ['$ref' => '#/components/requestBodies/UserBody'], + $requestBody->jsonSerialize(), + ); + } +} diff --git a/tests/Unit/Schema/Parser/ReferenceOverrideTest.php b/tests/Unit/Schema/Parser/ReferenceOverrideTest.php index d402b6d7..9dd0c079 100644 --- a/tests/Unit/Schema/Parser/ReferenceOverrideTest.php +++ b/tests/Unit/Schema/Parser/ReferenceOverrideTest.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Test\Unit\Schema\Parser; use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; @@ -16,6 +17,7 @@ #[CoversClass(Schema::class)] #[CoversClass(Parameter::class)] +#[CoversClass(RequestBody::class)] #[CoversClass(Response::class)] #[CoversClass(RefResolver::class)] #[CoversClass(JsonParser::class)] @@ -114,6 +116,41 @@ public function response_reference_can_override_summary(): void self::assertSame('Override summary', $response->refSummary); } + #[Test] + public function request_body_reference_can_override_summary(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"requestBodies":{"UserBody":{"description":"Original description","content":{"application/json":{"schema":{"type":"object"}}}}}},"paths":{"/test":{"post":{"requestBody":{"$ref":"#/components/requestBodies/UserBody","summary":"Override summary"},"responses":{"201":{"description":"Created"}}}}}}'; + + $document = $this->parser->parse($json); + + $requestBody = $document->paths?->paths['/test']->post?->requestBody; + + self::assertNotNull($requestBody); + self::assertSame('#/components/requestBodies/UserBody', $requestBody->ref); + self::assertSame('Override summary', $requestBody->refSummary); + } + + #[Test] + public function request_body_ref_serializes_only_reference_fields(): void + { + $requestBody = new RequestBody( + ref: '#/components/requestBodies/UserBody', + refSummary: 'Override summary', + refDescription: 'Override description', + description: 'Should not appear', + required: true, + ); + + $serialized = $requestBody->jsonSerialize(); + + self::assertCount(3, $serialized); + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + self::assertSame('Override description', $serialized['description']); + self::assertArrayNotHasKey('required', $serialized); + } + #[Test] public function reference_without_override_works(): void { diff --git a/tests/Unit/Validator/Schema/RefResolverRequestBodyTest.php b/tests/Unit/Validator/Schema/RefResolverRequestBodyTest.php new file mode 100644 index 00000000..afcfd0d4 --- /dev/null +++ b/tests/Unit/Validator/Schema/RefResolverRequestBodyTest.php @@ -0,0 +1,293 @@ +resolver = new RefResolver(); + } + + #[Test] + public function resolve_request_body_returns_request_body_instance(): void + { + $requestBody = new RequestBody(description: 'The user to create', required: true); + $document = $this->documentWith($requestBody); + + $result = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + + $this->assertSame($requestBody, $result); + $this->assertSame('The user to create', $result->description); + $this->assertTrue($result->required); + } + + #[Test] + public function resolve_request_body_preserves_content(): void + { + $requestBody = new RequestBody( + content: new Content(mediaTypes: [ + 'application/json' => new MediaType(schema: new Schema(type: 'object')), + ]), + required: true, + ); + $document = $this->documentWith($requestBody); + + $result = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + + $this->assertNotNull($result->content); + $this->assertArrayHasKey('application/json', $result->content->mediaTypes); + } + + #[Test] + public function resolve_request_body_caches_result(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $first = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + $second = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + + $this->assertSame($first, $second); + } + + #[Test] + public function resolve_request_body_follows_chained_ref(): void + { + $document = $this->documentWith( + new RequestBody(ref: '#/components/requestBodies/Body1'), + new RequestBody(description: 'Final', required: true), + ); + + $result = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + + $this->assertNull($result->ref); + $this->assertSame('Final', $result->description); + $this->assertTrue($result->required); + } + + #[Test] + public function resolve_request_body_throws_for_nonexistent_ref(): void + { + $document = new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(), + ); + + $this->expectException(UnresolvableRefException::class); + + $this->resolver->resolveRequestBody('#/components/requestBodies/Missing', $document); + } + + #[Test] + public function resolve_request_body_throws_for_document_without_components(): void + { + $document = new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + ); + + $this->expectException(UnresolvableRefException::class); + + $this->resolver->resolveRequestBody('#/components/requestBodies/Any', $document); + } + + #[Test] + public function resolve_request_body_throws_when_ref_points_to_schema(): void + { + $document = new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['User' => new Schema(type: 'object')]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Expected RequestBody but got'); + + $this->resolver->resolveRequestBody('#/components/schemas/User', $document); + } + + #[Test] + public function resolve_request_body_throws_when_ref_points_to_response(): void + { + $document = new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(responses: ['Ok' => new Response(description: 'OK')]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Expected RequestBody but got'); + + $this->resolver->resolveRequestBody('#/components/responses/Ok', $document); + } + + #[Test] + public function resolve_response_throws_when_ref_points_to_request_body(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Expected Response but got'); + + $this->resolver->resolveResponse('#/components/requestBodies/Body0', $document); + } + + #[Test] + public function resolve_parameter_throws_when_ref_points_to_request_body(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Expected Parameter but got'); + + $this->resolver->resolveParameter('#/components/requestBodies/Body0', $document); + } + + #[Test] + public function resolve_schema_throws_when_ref_points_to_request_body(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Expected Schema but got'); + + $this->resolver->resolve('#/components/requestBodies/Body0', $document); + } + + #[Test] + public function resolve_request_body_throws_for_non_local_ref(): void + { + $document = new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('External ref not resolved. Builtin FileExternalRefResolver allows only'); + + $this->resolver->resolveRequestBody('https://example.com/bodies.yaml', $document); + } + + #[Test] + public function resolve_request_body_throws_on_circular_ref(): void + { + $document = $this->documentWith( + new RequestBody(ref: '#/components/requestBodies/Body1'), + new RequestBody(ref: '#/components/requestBodies/Body0'), + ); + + $this->expectException(UnresolvableRefException::class); + + $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + } + + #[Test] + public function resolve_with_override_returns_same_instance_when_not_a_reference(): void + { + $requestBody = new RequestBody(description: 'Inline', required: true); + $document = $this->documentWith(new RequestBody(required: false)); + + $result = $this->resolver->resolveRequestBodyWithOverride($requestBody, $document); + + $this->assertSame($requestBody, $result); + } + + #[Test] + public function resolve_with_override_replaces_description_and_keeps_content_and_required(): void + { + $target = new RequestBody( + description: 'Declared in components', + content: new Content(mediaTypes: ['application/json' => new MediaType()]), + required: true, + ); + $document = $this->documentWith($target); + + $result = $this->resolver->resolveRequestBodyWithOverride( + new RequestBody( + ref: '#/components/requestBodies/Body0', + refDescription: 'Overridden at the call site', + ), + $document, + ); + + $this->assertNull($result->ref); + $this->assertSame('Overridden at the call site', $result->description); + $this->assertSame($target->content, $result->content); + $this->assertTrue($result->required); + } + + #[Test] + public function resolve_with_override_falls_back_to_the_referenced_description(): void + { + $document = $this->documentWith(new RequestBody(description: 'Declared in components', required: true)); + + $result = $this->resolver->resolveRequestBodyWithOverride( + new RequestBody(ref: '#/components/requestBodies/Body0'), + $document, + ); + + $this->assertSame('Declared in components', $result->description); + } + + #[Test] + public function resolve_with_override_drops_the_reference_sibling_summary(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $result = $this->resolver->resolveRequestBodyWithOverride( + new RequestBody( + ref: '#/components/requestBodies/Body0', + refSummary: 'Call-site summary', + ), + $document, + ); + + $this->assertNull($result->refSummary); + $this->assertNull($result->refDescription); + } + + #[Test] + public function clear_discards_the_request_body_cache(): void + { + $document = $this->documentWith(new RequestBody(required: true)); + + $first = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + $this->resolver->clear(); + $second = $this->resolver->resolveRequestBody('#/components/requestBodies/Body0', $document); + + $this->assertSame($first, $second); + } + + private function documentWith(RequestBody ...$bodies): OpenApiDocument + { + $named = []; + + foreach ($bodies as $index => $body) { + $named['Body' . $index] = $body; + } + + return new OpenApiDocument( + openapi: '3.1.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(requestBodies: $named), + ); + } +}