diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e73..14c7098 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 73d03c7..e419673 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 b82b610..3d378e0 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 a2b7ee0..201cb3a 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 8474187..d175f12 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 645b6f9..c412797 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 d1b1c5b..0bd30e0 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 76afcdd..4933f60 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 0000000..657f763 --- /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 d402b6d..9dd0c07 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 0000000..afcfd0d --- /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), + ); + } +}