diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b059d9c183d..679cc06e8c8 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -393,18 +393,26 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection 'The "openapiContext" option is deprecated, use "openapi" instead.' ); $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); - } elseif ( - null === $openapiOperation->getRequestBody() && \in_array($method, ['PATCH', 'PUT', 'POST'], true) + } else if ( + \in_array($method, ['PATCH', 'PUT', 'POST'], true) && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) ) { - $operationInputSchemas = []; - foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); - $operationInputSchemas[$operationFormat] = $operationInputSchema; - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + $content = $openapiOperation->getRequestBody()?->getContent(); + if (null === $content) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + } + $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + $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, + )); } // TODO Remove in 4.0 @@ -758,17 +766,17 @@ private function hasParameter(Model\Operation $operation, Parameter $parameter): private function mergeParameter(Parameter $actual, Parameter $defined): Parameter { foreach ([ - 'name', - 'in', - 'description', - 'required', - 'deprecated', - 'allowEmptyValue', - 'style', - 'explode', - 'allowReserved', - 'example', - ] as $method) { + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method) { $newValue = $defined->{"get$method"}(); if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { $actual = $actual->{"with$method"}($newValue); diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php index b1f452f814d..6d85b4d07cb 100644 --- a/src/OpenApi/Model/RequestBody.php +++ b/src/OpenApi/Model/RequestBody.php @@ -17,16 +17,16 @@ final class RequestBody { use ExtensionTrait; - public function __construct(private string $description = '', private ?\ArrayObject $content = null, private bool $required = false) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private bool $required = false) { } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } - public function getContent(): \ArrayObject + public function getContent(): ?\ArrayObject { return $this->content; } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 7be076f3043..c0b4b079074 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -92,167 +92,172 @@ public function testInvoke(): void ])); $dummyResource = (new ApiResource())->withOperations(new Operations([ - 'ignored' => new NotExposed(), - 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), - 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'customDummyItem' => (new HttpOperation())->withMethod('HEAD')->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( - tags: ['Dummy', 'Profile'], - responses: [ - '202' => new OpenApiResponse( - description: 'Success', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], - ], - ]), - headers: new \ArrayObject([ - 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], - ]), - links: new \ArrayObject([ - 'Foo' => ['$ref' => '#/components/schemas/Dummy'], - ]), - ), - '205' => new OpenApiResponse(), - ], - description: 'Custom description', - externalDocs: new ExternalDocumentation( - description: 'See also', - url: 'http://schema.example.com/Dummy', - ), - parameters: [ - new Parameter( - name: 'param', - in: 'path', - description: 'Test parameter', - required: true, - ), - new Parameter( - name: 'id', - in: 'path', - description: 'Replace parameter', - required: true, - schema: ['type' => 'string', 'format' => 'uuid'], - ), - ], - requestBody: new RequestBody( - description: 'Custom request body', - content: new \ArrayObject([ - 'multipart/form-data' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'file' => [ - 'type' => 'string', - 'format' => 'binary', - ], + 'ignored' => new NotExposed(), + 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), + 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'customDummyItem' => (new HttpOperation())->withMethod('HEAD')->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( + tags: ['Dummy', 'Profile'], + responses: [ + '202' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], ], - ], - ], - ]), - required: true, - ), - extensionProperties: ['x-visibility' => 'hide'], - )), - 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), - 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), - 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), - 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( - parameters: [ - new Parameter( - name: 'page', - in: 'query', - description: 'Test modified collection page number', - required: false, - allowEmptyValue: true, - schema: ['type' => 'integer', 'default' => 1], + ]), + headers: new \ArrayObject([ + 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], + ]), + links: new \ArrayObject([ + 'Foo' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '205' => new OpenApiResponse(), + ], + description: 'Custom description', + externalDocs: new ExternalDocumentation( + description: 'See also', + url: 'http://schema.example.com/Dummy', ), - ], - ))->withOperation($baseOperation), - 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), - // Filtered - 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), - // Paginated - 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') - ->withPaginationClientEnabled(true) - ->withPaginationClientItemsPerPage(true) - ->withPaginationItemsPerPage(20) - ->withPaginationMaximumItemsPerPage(80) - ->withOperation($baseOperation), - 'postDummyCollectionWithRequestBody' => (new Post())->withUriTemplate('/dummiesRequestBody')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - requestBody: new RequestBody( - description: 'List of Ids', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'ids' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'example' => [ - '1e677e04-d461-4389-bedc-6d1b665cc9d6', - '01111b43-f53a-4d50-8639-148850e5da19', + parameters: [ + new Parameter( + name: 'param', + in: 'path', + description: 'Test parameter', + required: true, + ), + new Parameter( + name: 'id', + in: 'path', + description: 'Replace parameter', + required: true, + schema: ['type' => 'string', 'format' => 'uuid'], + ), + ], + requestBody: new RequestBody( + description: 'Custom request body', + content: new \ArrayObject([ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', ], ], ], ], - ], - ]), - ), - )), - 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '200' => new OpenApiResponse( - description: 'Success', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], - ], - ]), - headers: new \ArrayObject([ - 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], - ]), - links: new \ArrayObject([ - 'link' => ['$ref' => '#/components/schemas/Dummy'], ]), + required: true, ), - '400' => new OpenApiResponse( - description: 'Error', - ), - ], - )), - 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '200' => new OpenApiResponse( - description: 'Success', - ), - ], - )), - 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '201' => new OpenApiResponse( - description: 'Created', + extensionProperties: ['x-visibility' => 'hide'], + )), + 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), + 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), + 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), + 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( + parameters: [ + new Parameter( + name: 'page', + in: 'query', + description: 'Test modified collection page number', + required: false, + allowEmptyValue: true, + schema: ['type' => 'integer', 'default' => 1], + ), + ], + ))->withOperation($baseOperation), + 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), + // Filtered + 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), + // Paginated + 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') + ->withPaginationClientEnabled(true) + ->withPaginationClientItemsPerPage(true) + ->withPaginationItemsPerPage(20) + ->withPaginationMaximumItemsPerPage(80) + ->withOperation($baseOperation), + 'postDummyCollectionWithRequestBody' => (new Post())->withUriTemplate('/dummiesRequestBody')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + description: 'List of Ids', content: new \ArrayObject([ 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'ids' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'example' => [ + '1e677e04-d461-4389-bedc-6d1b665cc9d6', + '01111b43-f53a-4d50-8639-148850e5da19', + ], + ], + ], + ], ], ]), - headers: new \ArrayObject([ - 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], - ]), - links: new \ArrayObject([ - 'link' => ['$ref' => '#/components/schemas/Dummy'], - ]), ), - '400' => new OpenApiResponse( - description: 'Error', + )), + 'postDummyCollectionWithRequestBodyWithoutContent' => (new Post())->withUriTemplate('/dummiesRequestBodyWithoutContent')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + description: 'Extended description for the new Dummy resource', ), - ], - )), - 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), - ]) + )), + 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + ), + ], + )), + 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '201' => new OpenApiResponse( + description: 'Created', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + ]) ); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withShortName('Parameter')->withDescription('This is a dummy'); @@ -872,6 +877,36 @@ public function testInvoke(): void deprecated: false, ), $requestBodyPath->getPost()); + $requestBodyPath = $paths->getPath('/dummiesRequestBodyWithoutContent'); + $this->assertEquals(new Operation( + 'postDummyCollectionWithRequestBodyWithoutContent', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response('Invalid input'), + '422' => new Response('Unprocessable entity'), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'Extended description for the new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + false + ), + deprecated: false, + ), $requestBodyPath->getPost()); + $dummyItemPath = $paths->getPath('/dummyitems/{id}'); $this->assertEquals(new Operation( 'putDummyItemWithResponse',