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.