From b85dbd0d6efc32c8dc2b818ec8a43944846bcf0d Mon Sep 17 00:00:00 2001 From: Rene Lima Date: Sat, 8 Apr 2023 14:05:20 +0200 Subject: [PATCH] [HttpKernel] Add MapUploadedFile attribute Signed-off-by: Rene Lima --- .../HttpKernel/Attribute/MapUploadedFile.php | 33 ++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../RequestPayloadValueResolver.php | 40 +- .../UploadedFileValueResolverTest.php | 343 ++++++++++++++++++ .../UploadedFile/file-big.txt | 1 + .../UploadedFile/file-small.txt | 1 + 6 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 src/Symfony/Component/HttpKernel/Attribute/MapUploadedFile.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UploadedFileValueResolverTest.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-big.txt create mode 100644 src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-small.txt diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapUploadedFile.php b/src/Symfony/Component/HttpKernel/Attribute/MapUploadedFile.php new file mode 100644 index 0000000000000..f90b511dc73f3 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapUploadedFile.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Validator\Constraint; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapUploadedFile extends ValueResolver +{ + public ArgumentMetadata $metadata; + + public function __construct( + /** @var Constraint|array|null */ + public Constraint|array|null $constraints = null, + public ?string $name = null, + string $resolver = RequestPayloadValueResolver::class, + public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY, + ) { + parent::__construct($resolver); + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 2e79d13b66d95..81daf2166f708 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Add `NearMissValueResolverException` to let value resolvers report when an argument could be under their watch but failed to be resolved * Add `$type` argument to `#[MapRequestPayload]` that allows mapping a list of items * Deprecate `Extension::addAnnotatedClassesToCompile()` and related code infrastructure + * Add `#[MapUploadedFile]` attribute to fetch, validate, and inject uploaded files into controller arguments 7.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index 83e8abe7ffa93..c35d5e7e29381 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -12,9 +12,11 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Attribute\MapUploadedFile; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; @@ -29,6 +31,7 @@ use Symfony\Component\Serializer\Exception\UnsupportedFormatException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Exception\ValidationFailedException; @@ -69,13 +72,14 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable { $attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? $argument->getAttributesOfType(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] + ?? $argument->getAttributesOfType(MapUploadedFile::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; if (!$attribute) { return []; } - if ($argument->isVariadic()) { + if (!$attribute instanceof MapUploadedFile && $argument->isVariadic()) { throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName())); } @@ -100,24 +104,27 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo foreach ($arguments as $i => $argument) { if ($argument instanceof MapQueryString) { - $payloadMapper = 'mapQueryString'; + $payloadMapper = $this->mapQueryString(...); $validationFailedCode = $argument->validationFailedStatusCode; } elseif ($argument instanceof MapRequestPayload) { - $payloadMapper = 'mapRequestPayload'; + $payloadMapper = $this->mapRequestPayload(...); + $validationFailedCode = $argument->validationFailedStatusCode; + } elseif ($argument instanceof MapUploadedFile) { + $payloadMapper = $this->mapUploadedFile(...); $validationFailedCode = $argument->validationFailedStatusCode; } else { continue; } $request = $event->getRequest(); - if (!$type = $argument->metadata->getType()) { + if (!$argument->metadata->getType()) { throw new \LogicException(sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->metadata->getName())); } if ($this->validator) { $violations = new ConstraintViolationList(); try { - $payload = $this->$payloadMapper($request, $type, $argument); + $payload = $payloadMapper($request, $argument->metadata, $argument); } catch (PartialDenormalizationException $e) { $trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p); foreach ($e->getErrors() as $error) { @@ -137,7 +144,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } if (null !== $payload && !\count($violations)) { - $violations->addAll($this->validator->validate($payload, null, $argument->validationGroups ?? null)); + $constraints = $argument->constraints ?? null; + if (\is_array($payload) && !empty($constraints) && !$constraints instanceof Assert\All) { + $constraints = new Assert\All($constraints); + } + $violations->addAll($this->validator->validate($payload, $constraints, $argument->validationGroups ?? null)); } if (\count($violations)) { @@ -145,7 +156,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } } else { try { - $payload = $this->$payloadMapper($request, $type, $argument); + $payload = $payloadMapper($request, $argument->metadata, $argument); } catch (PartialDenormalizationException $e) { throw HttpException::fromStatusCode($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e); } @@ -172,16 +183,16 @@ public static function getSubscribedEvents(): array ]; } - private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object + private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object { if (!$data = $request->query->all()) { return null; } - return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ['filter_bool' => true]); + return $this->serializer->denormalize($data, $argument->getType(), null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ['filter_bool' => true]); } - private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): object|array|null + private function mapRequestPayload(Request $request, ArgumentMetadata $argument, MapRequestPayload $attribute): object|array|null { if (null === $format = $request->getContentTypeFormat()) { throw new UnsupportedMediaTypeHttpException('Unsupported format.'); @@ -191,8 +202,10 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay throw new UnsupportedMediaTypeHttpException(sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format)); } - if ('array' === $type && null !== $attribute->type) { + if ('array' === $argument->getType() && null !== $attribute->type) { $type = $attribute->type.'[]'; + } else { + $type = $argument->getType(); } if ($data = $request->request->all()) { @@ -217,4 +230,9 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay throw new BadRequestHttpException(sprintf('Request payload contains invalid "%s" property.', $e->property), $e); } } + + private function mapUploadedFile(Request $request, ArgumentMetadata $argument, MapUploadedFile $attribute): UploadedFile|array|null + { + return $request->files->get($attribute->name ?? $argument->getName(), []); + } } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UploadedFileValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UploadedFileValueResolverTest.php new file mode 100644 index 0000000000000..5eb0d32483ed5 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UploadedFileValueResolverTest.php @@ -0,0 +1,343 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapUploadedFile; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\ValidatorBuilder; + +class UploadedFileValueResolverTest extends TestCase +{ + private const FIXTURES_BASE_PATH = __DIR__.'/../../Fixtures/Controller/ArgumentResolver/UploadedFile'; + + /** + * @dataProvider provideContext + */ + public function testDefaults(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(); + $argument = new ArgumentMetadata( + 'foo', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + + /** @var UploadedFile $data */ + $data = $event->getArguments()[0]; + + $this->assertInstanceOf(UploadedFile::class, $data); + $this->assertSame('file-small.txt', $data->getFilename()); + $this->assertSame(36, $data->getSize()); + } + + /** + * @dataProvider provideContext + */ + public function testEmpty(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(); + $argument = new ArgumentMetadata( + 'qux', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + $data = $event->getArguments()[0]; + + $this->assertEmpty($data); + } + + /** + * @dataProvider provideContext + */ + public function testCustomName(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(name: 'bar'); + $argument = new ArgumentMetadata( + 'foo', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + + /** @var UploadedFile $data */ + $data = $event->getArguments()[0]; + + $this->assertInstanceOf(UploadedFile::class, $data); + $this->assertSame('file-big.txt', $data->getFilename()); + $this->assertSame(71, $data->getSize()); + } + + /** + * @dataProvider provideContext + */ + public function testConstraintsWithoutViolation(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(constraints: new Assert\File(maxSize: 100)); + $argument = new ArgumentMetadata( + 'bar', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + + /** @var UploadedFile $data */ + $data = $event->getArguments()[0]; + + $this->assertInstanceOf(UploadedFile::class, $data); + $this->assertSame('file-big.txt', $data->getFilename()); + $this->assertSame(71, $data->getSize()); + } + + /** + * @dataProvider provideContext + */ + public function testConstraintsWithViolation(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(constraints: new Assert\File(maxSize: 50)); + $argument = new ArgumentMetadata( + 'bar', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $this->expectException(HttpException::class); + $this->expectExceptionMessageMatches('/^The file is too large/'); + + $resolver->onKernelControllerArguments($event); + } + + /** + * @dataProvider provideContext + */ + public function testMultipleFilesArray(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(); + $argument = new ArgumentMetadata( + 'baz', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + + /** @var UploadedFile[] $data */ + $data = $event->getArguments()[0]; + + $this->assertCount(2, $data); + $this->assertSame('file-small.txt', $data[0]->getFilename()); + $this->assertSame(36, $data[0]->getSize()); + $this->assertSame('file-big.txt', $data[1]->getFilename()); + $this->assertSame(71, $data[1]->getSize()); + } + + /** + * @dataProvider provideContext + */ + public function testMultipleFilesArrayConstraints(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(constraints: new Assert\File(maxSize: 50)); + $argument = new ArgumentMetadata( + 'baz', + UploadedFile::class, + false, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $this->expectException(HttpException::class); + $this->expectExceptionMessageMatches('/^The file is too large/'); + + $resolver->onKernelControllerArguments($event); + } + + /** + * @dataProvider provideContext + */ + public function testMultipleFilesVariadic(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(); + $argument = new ArgumentMetadata( + 'baz', + UploadedFile::class, + true, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + $resolver->onKernelControllerArguments($event); + + /** @var UploadedFile[] $data */ + $data = $event->getArguments()[0]; + + $this->assertCount(2, $data); + $this->assertSame('file-small.txt', $data[0]->getFilename()); + $this->assertSame(36, $data[0]->getSize()); + $this->assertSame('file-big.txt', $data[1]->getFilename()); + $this->assertSame(71, $data[1]->getSize()); + } + + /** + * @dataProvider provideContext + */ + public function testMultipleFilesVariadicConstraints(RequestPayloadValueResolver $resolver, Request $request) + { + $attribute = new MapUploadedFile(constraints: new Assert\File(maxSize: 50)); + $argument = new ArgumentMetadata( + 'baz', + UploadedFile::class, + true, + false, + null, + false, + [$attribute::class => $attribute] + ); + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + static function () {}, + $resolver->resolve($request, $argument), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $this->expectException(HttpException::class); + $this->expectExceptionMessageMatches('/^The file is too large/'); + + $resolver->onKernelControllerArguments($event); + } + + public static function provideContext(): iterable + { + $resolver = new RequestPayloadValueResolver( + new Serializer(), + (new ValidatorBuilder())->getValidator() + ); + $small = new UploadedFile( + self::FIXTURES_BASE_PATH.'/file-small.txt', + 'file-small.txt', + 'text/plain', + null, + true + ); + $big = new UploadedFile( + self::FIXTURES_BASE_PATH.'/file-big.txt', + 'file-big.txt', + 'text/plain', + null, + true + ); + $request = Request::create( + '/', + 'POST', + files: [ + 'foo' => $small, + 'bar' => $big, + 'baz' => [$small, $big], + ], + server: ['HTTP_CONTENT_TYPE' => 'multipart/form-data'] + ); + + yield 'standard' => [$resolver, $request]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-big.txt b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-big.txt new file mode 100644 index 0000000000000..450222562e934 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-big.txt @@ -0,0 +1 @@ +I'm not big, but I'm big enough to carry more than 50 bytes inside me. diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-small.txt b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-small.txt new file mode 100644 index 0000000000000..fa7c2c3885a77 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ArgumentResolver/UploadedFile/file-small.txt @@ -0,0 +1 @@ +I'm a file with less than 50 bytes. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy