From fab548d97ba79d41d741929a339bd8e1fa71fd28 Mon Sep 17 00:00:00 2001 From: Alex Jank Date: Tue, 7 Jan 2025 23:50:10 +0100 Subject: [PATCH] feat(symfony): describe MapUploadedFile property --- CHANGELOG.md | 1 + docs/symfony_attributes.rst | 54 +++++++ .../NelmioApiDocExtension.php | 8 + .../SymfonyMapUploadedFileDescriber.php | 48 ++++++ .../Controller/MapUploadedFileController.php | 80 ++++++++++ tests/Functional/ControllerTest.php | 9 ++ .../Fixtures/MapUploadedFileController.json | 144 ++++++++++++++++++ 7 files changed, 344 insertions(+) create mode 100644 src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php create mode 100644 tests/Functional/Controller/MapUploadedFileController.php create mode 100644 tests/Functional/Fixtures/MapUploadedFileController.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc5b94c5..c83a3b4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG ## 4.34.0 +* Added support for the `#[MapUploadedFile]` symfony controller argument attribute * Changed minimum Symfony version for 7.x from 7.0 to 7.1 ## 4.33.6 diff --git a/docs/symfony_attributes.rst b/docs/symfony_attributes.rst index fa149a074..fe59c80bf 100644 --- a/docs/symfony_attributes.rst +++ b/docs/symfony_attributes.rst @@ -75,6 +75,35 @@ Customizing the documentation of the request body can be done by adding the ``#[ groups: ["create"], ) +MapUploadedFile +------------------------------- + +Using the `Symfony MapUploadedFile`_ attribute allows NelmioApiDocBundle to automatically generate your request body documentation for your endpoint. + +.. versionadded:: 7.1 + + The :class:`Symfony\\Component\\HttpKernel\\Attribute\\MapUploadedFile` attribute was introduced in Symfony 7.1. + + +Modify generated documentation +~~~~~~~ + +Customizing the documentation of the uploaded file can be done by adding the ``#[OA\RequestBody]`` attribute with the corresponding ``#[OA\MediaType]`` and ``#[OA\Schema]`` to your controller method. + + .. code-block:: php-attributes + + #[OA\RequestBody( + description: 'Describe the body', + content: [ + new OA\MediaType('multipart/form-data', new OA\Schema( + properties: [new OA\Property( + property: 'file', + description: 'Describe the file' + )], + )), + ], + )] + Complete example ---------------------- @@ -104,6 +133,10 @@ Complete example use AppBundle\UserDTO; use AppBundle\UserQuery; use OpenApi\Attributes as OA; + use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; + use Symfony\Component\HttpKernel\Attribute\MapQueryString; + use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; + use Symfony\Component\HttpKernel\Attribute\MapUploadedFile; use Symfony\Component\Routing\Annotation\Route; class UserController @@ -147,6 +180,26 @@ Complete example { // ... } + + /** + * Upload a profile picture + */ + #[Route('/api/users/picture', methods: ['POST'])] + #[OA\RequestBody( + description: 'Content of the profile picture upload request', + content: [ + new OA\MediaType('multipart/form-data', new OA\Schema( + properties: [new OA\Property( + property: 'file', + description: 'File containing the profile picture', + )], + )), + ], + )] + public function createUser(#[MapUploadedFile] UploadedFile $picture) + { + // ... + } } Customization @@ -197,4 +250,5 @@ Make sure to use at least php 8.1 (attribute support) to make use of this functi .. _`Symfony MapQueryString`: https://symfony.com/doc/current/controller.html#mapping-the-whole-query-string .. _`Symfony MapQueryParameter`: https://symfony.com/doc/current/controller.html#mapping-query-parameters-individually .. _`Symfony MapRequestPayload`: https://symfony.com/doc/current/controller.html#mapping-request-payload +.. _`Symfony MapUploadedFile`: https://symfony.com/doc/current/controller.html#mapping-uploaded-files .. _`RouteArgumentDescriberInterface`: https://github.com/DjordyKoert/NelmioApiDocBundle/blob/master/src/RouteDescriber/RouteArgumentDescriber/RouteArgumentDescriberInterface.php diff --git a/src/DependencyInjection/NelmioApiDocExtension.php b/src/DependencyInjection/NelmioApiDocExtension.php index 757703632..e36cba464 100644 --- a/src/DependencyInjection/NelmioApiDocExtension.php +++ b/src/DependencyInjection/NelmioApiDocExtension.php @@ -28,6 +28,7 @@ use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryParameterDescriber; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryStringDescriber; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber; +use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapUploadedFileDescriber; use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder; use OpenApi\Generator; use Symfony\Component\Config\FileLocator; @@ -43,6 +44,7 @@ use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Attribute\MapUploadedFile; use Symfony\Component\Routing\RouteCollection; final class NelmioApiDocExtension extends Extension implements PrependExtensionInterface @@ -223,6 +225,12 @@ public function load(array $configs, ContainerBuilder $container): void ->setPublic(false) ->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]); } + + if (class_exists(MapUploadedFile::class)) { + $container->register('nelmio_api_doc.route_argument_describer.map_uploaded_file', SymfonyMapUploadedFileDescriber::class) + ->setPublic(false) + ->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]); + } } $bundles = $container->getParameter('kernel.bundles'); diff --git a/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php new file mode 100644 index 000000000..f0fc449fa --- /dev/null +++ b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php @@ -0,0 +1,48 @@ +getAttributes(MapUploadedFile::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) { + return; + } + + $name = $attribute->name ?? $argumentMetadata->getName(); + $body = Util::getChild($operation, OA\RequestBody::class); + + $mediaType = Util::getCollectionItem($body, OA\MediaType::class, [ + 'mediaType' => 'multipart/form-data' + ]); + + /** @var OA\Schema $schema */ + $schema = Util::getChild($mediaType, OA\Schema::class, [ + 'type' => 'object' + ]); + + $property = Util::getCollectionItem($schema, OA\Property::class, ['property' => $name]); + Util::modifyAnnotationValue($property, 'type', 'string'); + Util::modifyAnnotationValue($property, 'format', 'binary'); + } +} diff --git a/tests/Functional/Controller/MapUploadedFileController.php b/tests/Functional/Controller/MapUploadedFileController.php new file mode 100644 index 000000000..b2a166778 --- /dev/null +++ b/tests/Functional/Controller/MapUploadedFileController.php @@ -0,0 +1,80 @@ +=')) { + yield 'Symfony 7.1 MapUploadedFile attribute' => [ + [ + 'name' => 'MapUploadedFileController', + 'type' => $type, + ], + ]; + } } public static function provideAnnotationTestCases(): \Generator diff --git a/tests/Functional/Fixtures/MapUploadedFileController.json b/tests/Functional/Fixtures/MapUploadedFileController.json new file mode 100644 index 000000000..39f6e03f9 --- /dev/null +++ b/tests/Functional/Fixtures/MapUploadedFileController.json @@ -0,0 +1,144 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "", + "version": "0.0.0" + }, + "paths": { + "/article_map_uploaded_file": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_mapuploadedfile_createuploadfrommapuploadedfilepayload", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "upload": { + "type": "string", + "format": "binary" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/article_map_uploaded_file_nullable": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_mapuploadedfile_createuploadfrommapuploadedfilepayloadnullable", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "upload": { + "type": "string", + "format": "binary" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/article_map_uploaded_file_multiple": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_mapuploadedfile_createuploadfrommapuploadedfilepayloadmultiple", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "firstUpload": { + "type": "string", + "format": "binary" + }, + "secondUpload": { + "type": "string", + "format": "binary" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/article_map_uploaded_file_add_to_existing": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_mapuploadedfile_createuploadfrommapuploadedfileaddtoexisting", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "existing": { + "type": "string", + "format": "binary" + }, + "upload": { + "type": "string", + "format": "binary" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/article_map_uploaded_file_overwrite": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_mapuploadedfile_createuploadfrommapuploadedfileoverwrite", + "requestBody": { + "description": "Body if file upload request", + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "upload": { + "description": "A file", + "type": "string", + "format": "binary" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + } + } +} \ No newline at end of file