From 6bff8e0687ea52767d5e579f1c91d623cc264d45 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 6 Oct 2026 11:11:01 +0200 Subject: [PATCH] Keep #[MapUploadedFile] working and map payloads after controller attributes `RequestPayloadArrayResolver` decorates `argument_resolver.request_payload` and replaces it, so `#[MapUploadedFile]` arguments could not be resolved anymore. They are now delegated to the decorated resolver. The resolver also mapped payloads at priority 0 on `kernel.controller_arguments`. Since Symfony 8.1, controller attributes such as `#[IsGranted]` run at -10000, so a malformed payload got a 400 or a 422 before the request was denied. It now uses the priority of the decorated resolver. `symfony/browser-kit` accepts 7.4 again so that the PHP 8.2 and 8.3 jobs can install. --- composer.json | 2 +- config/services/controller.php | 1 + .../RequestPayloadArrayResolver.php | 11 ++--- .../Controller/Closed.php | 19 +++++++++ .../Controller/ClosedListener.php | 24 +++++++++++ .../Controller/ClosedPayload.php | 19 +++++++++ .../Controller/ClosedPayloadAction.php | 28 +++++++++++++ .../Controller/UploadFileAction.php | 28 +++++++++++++ .../App/NativeRequestMapping/config.yaml | 7 ++++ .../App/NativeRequestMapping/routes.yaml | 5 +++ tests/Functional/NativeRequestMappingTest.php | 41 +++++++++++++++++++ 11 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 tests/Functional/App/NativeRequestMapping/Controller/Closed.php create mode 100644 tests/Functional/App/NativeRequestMapping/Controller/ClosedListener.php create mode 100644 tests/Functional/App/NativeRequestMapping/Controller/ClosedPayload.php create mode 100644 tests/Functional/App/NativeRequestMapping/Controller/ClosedPayloadAction.php create mode 100644 tests/Functional/App/NativeRequestMapping/Controller/UploadFileAction.php create mode 100644 tests/Functional/App/NativeRequestMapping/config.yaml create mode 100644 tests/Functional/App/NativeRequestMapping/routes.yaml create mode 100644 tests/Functional/NativeRequestMappingTest.php diff --git a/composer.json b/composer.json index 910735c..b920611 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "require-dev": { "roave/security-advisories": "dev-latest", "phpunit/phpunit": "^10.0", - "symfony/browser-kit": "^8.0", + "symfony/browser-kit": "^7.4|^8.0", "friendsofphp/php-cs-fixer": "^3.54" }, "autoload": { diff --git a/config/services/controller.php b/config/services/controller.php index d856a89..7943114 100644 --- a/config/services/controller.php +++ b/config/services/controller.php @@ -41,6 +41,7 @@ service('serializer'), service('validator')->nullOnInvalid(), service('translator')->nullOnInvalid(), + service('.inner'), ]) ->tag('controller.targeted_value_resolver', ['name' => RequestPayloadArrayResolver::class]) ->tag('kernel.event_subscriber') diff --git a/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php b/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php index 9746208..0560e96 100644 --- a/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php +++ b/src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php @@ -19,11 +19,11 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; -use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Exception\UnsupportedFormatException; @@ -57,6 +57,7 @@ public function __construct( private readonly SerializerInterface&DenormalizerInterface $serializer, private readonly ?ValidatorInterface $validator = null, private readonly ?TranslatorInterface $translator = null, + private readonly ?RequestPayloadValueResolver $decorated = null, ) { } @@ -67,7 +68,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable ?? null; if (!$attribute) { - return []; + return $this->decorated?->resolve($request, $argument) ?? []; } if ($argument->isVariadic()) { @@ -160,13 +161,13 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } $event->setArguments($arguments); + + $this->decorated?->onKernelControllerArguments($event); } public static function getSubscribedEvents(): array { - return [ - KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments', - ]; + return RequestPayloadValueResolver::getSubscribedEvents(); } private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object diff --git a/tests/Functional/App/NativeRequestMapping/Controller/Closed.php b/tests/Functional/App/NativeRequestMapping/Controller/Closed.php new file mode 100644 index 0000000..1f8bd29 --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/Controller/Closed.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller; + +#[\Attribute(\Attribute::TARGET_METHOD)] +class Closed +{ +} diff --git a/tests/Functional/App/NativeRequestMapping/Controller/ClosedListener.php b/tests/Functional/App/NativeRequestMapping/Controller/ClosedListener.php new file mode 100644 index 0000000..e4e73fc --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/Controller/ClosedListener.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller; + +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +class ClosedListener +{ + public function onClosed(): void + { + throw new AccessDeniedHttpException(); + } +} diff --git a/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayload.php b/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayload.php new file mode 100644 index 0000000..fd42fa4 --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayload.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller; + +class ClosedPayload +{ + public string $name; +} diff --git a/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayloadAction.php b/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayloadAction.php new file mode 100644 index 0000000..5d2e53e --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/Controller/ClosedPayloadAction.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller; + +use OpenSolid\OpenApiBundle\Attribute\Payload; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class ClosedPayloadAction +{ + #[Route('/closed', methods: 'POST')] + #[Closed] + public function __invoke(#[Payload] ClosedPayload $payload): Response + { + return new Response($payload->name); + } +} diff --git a/tests/Functional/App/NativeRequestMapping/Controller/UploadFileAction.php b/tests/Functional/App/NativeRequestMapping/Controller/UploadFileAction.php new file mode 100644 index 0000000..38271bf --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/Controller/UploadFileAction.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller; + +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapUploadedFile; +use Symfony\Component\Routing\Attribute\Route; + +class UploadFileAction +{ + #[Route('/upload', methods: 'POST')] + public function __invoke(#[MapUploadedFile] UploadedFile $file): Response + { + return new Response($file->getClientOriginalName()); + } +} diff --git a/tests/Functional/App/NativeRequestMapping/config.yaml b/tests/Functional/App/NativeRequestMapping/config.yaml new file mode 100644 index 0000000..ac7b883 --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/config.yaml @@ -0,0 +1,7 @@ +imports: + - { resource: ../config.yaml } + +services: + OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller\ClosedListener: + tags: + - { name: kernel.event_listener, event: 'kernel.controller_arguments.OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller\Closed', method: onClosed } diff --git a/tests/Functional/App/NativeRequestMapping/routes.yaml b/tests/Functional/App/NativeRequestMapping/routes.yaml new file mode 100644 index 0000000..fd882e6 --- /dev/null +++ b/tests/Functional/App/NativeRequestMapping/routes.yaml @@ -0,0 +1,5 @@ +controllers: + resource: + path: ./Controller/ + namespace: OpenSolid\Tests\OpenApiBundle\Functional\App\NativeRequestMapping\Controller + type: attribute diff --git a/tests/Functional/NativeRequestMappingTest.php b/tests/Functional/NativeRequestMappingTest.php new file mode 100644 index 0000000..d015a4a --- /dev/null +++ b/tests/Functional/NativeRequestMappingTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace OpenSolid\Tests\OpenApiBundle\Functional; + +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpKernel\EventListener\ControllerAttributesListener; + +class NativeRequestMappingTest extends AbstractWebTestCase +{ + public function testMapUploadedFile(): void + { + $client = self::createClient(); + $client->request('POST', '/upload', [], ['file' => new UploadedFile(__FILE__, 'upload.txt', 'text/plain', null, true)]); + + self::assertResponseIsSuccessful(); + $this->assertSame('upload.txt', $client->getResponse()->getContent()); + } + + public function testControllerAttributesRunBeforeThePayloadIsMapped(): void + { + if (!class_exists(ControllerAttributesListener::class)) { + $this->markTestSkipped('Controller attribute events require Symfony 8.1 or later.'); + } + + $client = self::createClient(); + $client->request('POST', '/closed', [], [], ['CONTENT_TYPE' => 'application/json'], '{"name": '); + + self::assertResponseStatusCodeSame(403); + } +}