From cef9153e9a2f25586c5d7686fe98b9fdea40c7f6 Mon Sep 17 00:00:00 2001 From: Bram Leeda Date: Mon, 12 Oct 2026 00:17:48 +0200 Subject: [PATCH] fix(openapi): retain generated schema when custom request body media type examples are defined --- src/OpenApi/Factory/OpenApiFactory.php | 63 ++++++++--- .../Tests/Factory/OpenApiFactoryTest.php | 107 ++++++++++++++++++ .../RequestBodyWithExamplesResource.php | 55 +++++++++ tests/Functional/OpenApiTest.php | 38 +++++++ 4 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/RequestBodyWithExamplesResource.php diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 5b554a2a0d..06a70c0b70 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -492,24 +492,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection (\in_array($method, ['PATCH', 'PUT', 'POST'], true) || true === $operation->canDeserialize()) && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) ) { - $content = $openapiOperation->getRequestBody()?->getContent(); - if (null === $content) { - $operationInputSchemas = []; - foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = null; - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); - - $operationInputSchemas[$operationFormat] = $operationInputSchema; - } - $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + + $operationInputSchemas[$operationFormat] = $operationInputSchema; } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody( - description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), - content: $content, - required: $openapiOperation->getRequestBody()?->getRequired() ?? true, - )); + $openapiOperation = $openapiOperation->withRequestBody($this->buildOpenApiRequestBody($requestMimeTypes, $operationInputSchemas, $openapiOperation, $method, $resourceShortName)); } // RFC 10008: the HTTP QUERY method carries its criteria in the request body, not the URI @@ -563,6 +554,46 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat return $openapiOperation->withResponse($status, $response); } + /** + * @param array $requestMimeTypes + * @param array $operationInputSchemas + */ + private function buildOpenApiRequestBody(array $requestMimeTypes, array $operationInputSchemas, Operation $openapiOperation, string $method, string $resourceShortName): RequestBody + { + $content = $openapiOperation->getRequestBody()?->getContent(); + if (null === $content) { + $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); + } else { + $generatedContent = $this->buildContent($requestMimeTypes, $operationInputSchemas); + foreach ($generatedContent as $mimeType => $generatedMediaType) { + if (!isset($content[$mimeType])) { + $content[$mimeType] = $generatedMediaType; + continue; + } + + $mediaType = $content[$mimeType]; + $format = $requestMimeTypes[$mimeType] ?? null; + $operationInputSchema = $format && isset($operationInputSchemas[$format]) ? $operationInputSchemas[$format] : null; + + if ($operationInputSchema) { + $schemaArrayObject = new \ArrayObject($operationInputSchema->getArrayCopy(false)); + if ($mediaType instanceof MediaType && null === $mediaType->getSchema()) { + $content[$mimeType] = $mediaType->withSchema($schemaArrayObject); + } elseif ((\is_array($mediaType) || $mediaType instanceof \ArrayObject) && !isset($mediaType['schema'])) { + $mediaType['schema'] = $schemaArrayObject; + $content[$mimeType] = $mediaType; + } + } + } + } + + return new RequestBody( + description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), + content: $content, + required: $openapiOperation->getRequestBody()?->getRequired() ?? true, + ); + } + /** * @param array $responseMimeTypes * @param array $operationSchemas diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index bd6a43dcc5..a393e1d964 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -216,6 +216,28 @@ public function testInvoke(): void description: 'Extended description for the new Dummy resource', ), )), + 'postDummyCollectionWithRequestBodyMediaTypeWithoutSchema' => (new Post())->withUriTemplate('/dummiesRequestBodyMediaTypeWithoutSchema')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + content: new \ArrayObject([ + 'application/ld+json' => new MediaType( + examples: new \ArrayObject([ + 'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']), + ]), + ), + ]), + ), + )), + 'postDummyCollectionWithRequestBodyArrayObjectWithoutSchema' => (new Post())->withUriTemplate('/dummiesRequestBodyArrayObjectWithoutSchema')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + content: new \ArrayObject([ + 'application/ld+json' => new \ArrayObject([ + 'examples' => new \ArrayObject([ + 'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']), + ]), + ]), + ]), + ), + )), 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( responses: [ '200' => new OpenApiResponse( @@ -1065,6 +1087,7 @@ public function testInvoke(): void ], ], ], + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), false ), @@ -1107,6 +1130,90 @@ public function testInvoke(): void ), ), $requestBodyPath->getPost()); + $requestBodyPath = $paths->getPath('/dummiesRequestBodyMediaTypeWithoutSchema'); + $this->assertEquals(new Operation( + 'postDummyCollectionWithRequestBodyMediaTypeWithoutSchema', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'The new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new MediaType( + schema: new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']), + examples: new \ArrayObject([ + 'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']), + ]), + ), + ]), + false + ), + ), $requestBodyPath->getPost()); + + $requestBodyPath = $paths->getPath('/dummiesRequestBodyArrayObjectWithoutSchema'); + $this->assertEquals(new Operation( + 'postDummyCollectionWithRequestBodyArrayObjectWithoutSchema', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'The new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new \ArrayObject([ + 'schema' => new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']), + 'examples' => new \ArrayObject([ + 'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']), + ]), + ]), + ]), + false + ), + ), $requestBodyPath->getPost()); + $dummyItemPath = $paths->getPath('/dummyitems/{id}'); $this->assertEquals(new Operation( 'putDummyItemWithResponse', diff --git a/tests/Fixtures/TestBundle/ApiResource/RequestBodyWithExamplesResource.php b/tests/Fixtures/TestBundle/ApiResource/RequestBodyWithExamplesResource.php new file mode 100644 index 0000000000..75e851267d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/RequestBodyWithExamplesResource.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\Example; +use ApiPlatform\OpenApi\Model\MediaType; +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\RequestBody; + +#[ApiResource( + operations: [ + new Post( + status: 201, + openapi: new Operation( + requestBody: new RequestBody( + content: new \ArrayObject([ + 'application/ld+json' => new MediaType( + examples: new \ArrayObject([ + 'hello world' => new Example(description: 'Submitting a "hello world" entity', value: ['id' => 1, 'name' => 'Hello world']), + ]), + ), + 'application/json' => [ + 'examples' => [ + 'foo bar' => ['description' => 'Submitting a "foo bar" entity', 'value' => ['id' => 2, 'name' => 'Foo bar']], + ], + ], + ]), + ), + ), + ), + ], +)] +class RequestBodyWithExamplesResource +{ + public function __construct( + #[ApiProperty(identifier: true)] + public ?int $id = null, + public ?string $name = null, + ) { + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 7ae7dee890..b77309a536 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\OpenApiPartialUpdateResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParameterOnPropertyWithDefaultKey; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParentAttribute; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\RequestBodyWithExamplesResource; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; @@ -120,6 +121,7 @@ public static function getResources(): array ParameterOnProperties::class, ParameterOnPropertiesWithHeaderParameter::class, ParameterOnPropertyWithDefaultKey::class, + RequestBodyWithExamplesResource::class, ]; } @@ -138,6 +140,42 @@ public function testOpenApiReferenceInResponsesUsesDollarRef(): void $this->assertArrayNotHasKey('ref', $responses['401']); } + public function testRequestBodyExamplesRetainAutoGeneratedSchema(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $postContent = $json['paths']['/request_body_with_examples_resources']['post']['requestBody']['content']; + + $expectedMimeTypes = [ + 'application/ld+json' => '#/components/schemas/RequestBodyWithExamplesResource', + 'application/hal+json' => '#/components/schemas/RequestBodyWithExamplesResource.jsonhal', + 'application/vnd.api+json' => '#/components/schemas/RequestBodyWithExamplesResource', + 'application/xml' => '#/components/schemas/RequestBodyWithExamplesResource', + 'text/xml' => '#/components/schemas/RequestBodyWithExamplesResource', + 'application/json' => '#/components/schemas/RequestBodyWithExamplesResource', + 'text/html' => '#/components/schemas/RequestBodyWithExamplesResource', + 'application/graphql' => '#/components/schemas/RequestBodyWithExamplesResource', + 'multipart/form-data' => '#/components/schemas/RequestBodyWithExamplesResource', + ]; + + foreach ($expectedMimeTypes as $mimeType => $expectedRef) { + $this->assertArrayHasKey($mimeType, $postContent); + $this->assertArrayHasKey('schema', $postContent[$mimeType]); + $this->assertSame($expectedRef, $postContent[$mimeType]['schema']['$ref']); + } + + $this->assertArrayHasKey('examples', $postContent['application/ld+json']); + $this->assertArrayHasKey('hello world', $postContent['application/ld+json']['examples']); + + $this->assertArrayHasKey('examples', $postContent['application/json']); + $this->assertArrayHasKey('foo bar', $postContent['application/json']['examples']); + } + public function testDeprecatedPutDoesNotLeakIntoNestedResourceSchema(): void { $response = self::createClient()->request('GET', '/docs', [