Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 75aad23

Browse filesBrowse files
Renerenedelima
Rene
authored andcommitted
[HttpKernel] Add MapUploadedFile attribute
1 parent f842797 commit 75aad23
Copy full SHA for 75aad23

File tree

7 files changed

+273
-10
lines changed
Filter options

7 files changed

+273
-10
lines changed

‎src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php
+200Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111

1212
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional;
1313

14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
1415
use Symfony\Component\HttpFoundation\JsonResponse;
1516
use Symfony\Component\HttpFoundation\Request;
1617
use Symfony\Component\HttpFoundation\Response;
1718
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
1819
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
20+
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
1921
use Symfony\Component\Validator\Constraints as Assert;
2022

2123
class ApiAttributesTest extends AbstractWebTestCase
@@ -346,6 +348,171 @@ public static function mapRequestPayloadProvider(): iterable
346348
'expectedStatusCode' => 422,
347349
];
348350
}
351+
352+
public function testMapUploadedFileDefaults()
353+
{
354+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
355+
356+
$client->request(
357+
'POST',
358+
'/map-uploaded-file-defaults',
359+
[],
360+
['file' => new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'file-small.txt', 'text/plain')],
361+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
362+
);
363+
$response = $client->getResponse();
364+
365+
self::assertResponseIsSuccessful();
366+
self::assertStringEqualsFile(__DIR__.'/Fixtures/file-small.txt', $response->getContent());
367+
}
368+
369+
public function testMapUploadedFileCustomName()
370+
{
371+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
372+
373+
$client->request(
374+
'POST',
375+
'/map-uploaded-file-custom-name',
376+
[],
377+
[
378+
'foo' => new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'file-small.txt', 'text/plain'),
379+
'bar' => new UploadedFile(__DIR__.'/Fixtures/file-big.txt', 'file-big.txt', 'text/plain'),
380+
],
381+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
382+
);
383+
$response = $client->getResponse();
384+
385+
self::assertResponseIsSuccessful();
386+
self::assertStringEqualsFile(__DIR__.'/Fixtures/file-small.txt', $response->getContent());
387+
}
388+
389+
public function testMapUploadedFileNullable()
390+
{
391+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
392+
$client->request(
393+
'POST',
394+
'/map-uploaded-file-nullable',
395+
[],
396+
[],
397+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
398+
);
399+
$response = $client->getResponse();
400+
401+
self::assertResponseIsSuccessful();
402+
self::assertEmpty($response->getContent());
403+
}
404+
405+
public function testMapUploadedFileWithConstraints()
406+
{
407+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
408+
409+
$client->request(
410+
'POST',
411+
'/map-uploaded-file-with-constraints',
412+
[],
413+
['file' => new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'file-small.txt', 'text/plain')],
414+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
415+
);
416+
$response = $client->getResponse();
417+
418+
self::assertResponseIsSuccessful();
419+
self::assertStringEqualsFile(__DIR__.'/Fixtures/file-small.txt', $response->getContent());
420+
421+
$filePath = __DIR__.'/Fixtures/file-big.txt';
422+
$client->request(
423+
'POST',
424+
'/map-uploaded-file-with-constraints',
425+
[],
426+
['file' => new UploadedFile($filePath, 'file-big.txt', 'text/plain')],
427+
[
428+
'HTTP_ACCEPT' => 'application/json',
429+
'HTTP_CONTENT_TYPE' => 'multipart/form-data',
430+
],
431+
);
432+
$response = $client->getResponse();
433+
434+
$content = <<<JSON
435+
{
436+
"type": "https://symfony.com/errors/validation",
437+
"title": "Validation Failed",
438+
"status": 422,
439+
"detail": "The file is too large (71 bytes). Allowed maximum size is 50 bytes.",
440+
"violations": [
441+
{
442+
"propertyPath": "",
443+
"title": "The file is too large (71 bytes). Allowed maximum size is 50 bytes.",
444+
"template": "The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.",
445+
"parameters": {
446+
"{{ file }}": "\"$filePath\"",
447+
"{{ size }}": "71",
448+
"{{ limit }}": "50",
449+
"{{ suffix }}": "bytes",
450+
"{{ name }}": "\"file-big.txt\""
451+
},
452+
"type": "urn:uuid:df8637af-d466-48c6-a59d-e7126250a654"
453+
}
454+
]
455+
}
456+
JSON;
457+
458+
self::assertResponseIsUnprocessable();
459+
self::assertJsonStringEqualsJsonString($content, $response->getContent());
460+
}
461+
462+
public function testMapUploadedFileWithMultipleFilesArray()
463+
{
464+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
465+
466+
$client->request(
467+
'POST',
468+
'/map-uploaded-file-with-multiple-array',
469+
[],
470+
[
471+
'files' => [
472+
new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'file-small.txt', 'text/plain'),
473+
new UploadedFile(__DIR__.'/Fixtures/file-big.txt', 'file-big.txt', 'text/plain'),
474+
],
475+
],
476+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
477+
);
478+
$response = $client->getResponse();
479+
480+
self::assertResponseIsSuccessful();
481+
self::assertJsonStringEqualsJsonString(
482+
json_encode([2, 'file-small.txt', 'file-big.txt'], \JSON_THROW_ON_ERROR),
483+
$response->getContent()
484+
);
485+
}
486+
487+
public function testMapUploadedFileWithMultipleFilesVariadic()
488+
{
489+
$client = self::createClient(['test_case' => 'ApiAttributesTest']);
490+
491+
$client->request(
492+
'POST',
493+
'/map-uploaded-file-with-multiple-variadic',
494+
[],
495+
[
496+
'foo' => [
497+
new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'first.txt', 'text/plain'),
498+
new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'second.txt', 'text/plain'),
499+
new UploadedFile(__DIR__.'/Fixtures/file-small.txt', 'third.txt', 'text/plain'),
500+
],
501+
'bar' => [
502+
new UploadedFile(__DIR__.'/Fixtures/file-big.txt', 'big.txt', 'text/plain'),
503+
new UploadedFile(__DIR__.'/Fixtures/file-big.txt', 'huge.txt', 'text/plain'),
504+
],
505+
],
506+
['HTTP_CONTENT_TYPE' => 'multipart/form-data'],
507+
);
508+
$response = $client->getResponse();
509+
510+
self::assertResponseIsSuccessful();
511+
self::assertJsonStringEqualsJsonString(
512+
json_encode([3, 'first.txt', 'second.txt', 'third.txt'], \JSON_THROW_ON_ERROR),
513+
$response->getContent()
514+
);
515+
}
349516
}
350517

351518
class WithMapQueryStringController
@@ -385,6 +552,39 @@ public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $reque
385552
}
386553
}
387554

555+
class WithMapUploadedFileController
556+
{
557+
public function defaults(#[MapUploadedFile] UploadedFile $file): Response
558+
{
559+
return new Response($file->getContent());
560+
}
561+
562+
public function customName(#[MapUploadedFile(name: 'foo')] UploadedFile $bar): Response
563+
{
564+
return new Response($bar->getContent());
565+
}
566+
567+
public function nullable(#[MapUploadedFile] ?UploadedFile $file): Response
568+
{
569+
return new Response($file?->getContent());
570+
}
571+
572+
public function withConstraints(#[MapUploadedFile(constraints: new Assert\File(maxSize: 50))] ?UploadedFile $file): Response
573+
{
574+
return new Response($file->getContent());
575+
}
576+
577+
public function withMultipleFilesArray(#[MapUploadedFile(constraints: new Assert\All([new Assert\File(maxSize: 100)]))] ?array $files): JsonResponse
578+
{
579+
return new JsonResponse([\count($files), ...array_map(static fn ($current) => $current->getClientOriginalName(), $files)]);
580+
}
581+
582+
public function withMultipleFilesVariadic(#[MapUploadedFile(constraints: new Assert\All([new Assert\File(maxSize: 100)]))] UploadedFile ...$foo): JsonResponse
583+
{
584+
return new JsonResponse([\count($foo), ...array_map(static fn ($current) => $current->getClientOriginalName(), $foo)]);
585+
}
586+
}
587+
388588
class QueryString
389589
{
390590
public function __construct(
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
I'm not big, but I'm big enough to carry more than 50 bytes inside me.
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
I'm a file with less than 50 bytes.

‎src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml
+24Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,27 @@ map_query_string:
55
map_request_body:
66
path: /map-request-body.{_format}
77
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestPayloadController
8+
9+
map_uploaded_file_defaults:
10+
path: /map-uploaded-file-defaults
11+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::defaults
12+
13+
map_uploaded_file_custom_name:
14+
path: /map-uploaded-file-custom-name
15+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::customName
16+
17+
map_uploaded_file_nullable:
18+
path: /map-uploaded-file-nullable
19+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::nullable
20+
21+
map_uploaded_file_constraints:
22+
path: /map-uploaded-file-with-constraints
23+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::withConstraints
24+
25+
map_uploaded_file_multiple_array:
26+
path: /map-uploaded-file-with-multiple-array
27+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::withMultipleFilesArray
28+
29+
map_uploaded_file_multiple_variadic:
30+
path: /map-uploaded-file-with-multiple-variadic
31+
controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapUploadedFileController::withMultipleFilesVariadic
+28Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Attribute;
13+
14+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
15+
use Symfony\Component\Validator\Constraint;
16+
17+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
18+
class MapUploadedFile extends ValueResolver
19+
{
20+
public function __construct(
21+
public string|null $name = null,
22+
/** @var Constraint|array<Constraint>|null */
23+
public Constraint|array|null $constraints = null,
24+
string $resolver = RequestPayloadValueResolver::class,
25+
) {
26+
parent::__construct($resolver);
27+
}
28+
}

‎src/Symfony/Component/HttpKernel/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CHANGELOG
1313
* Introduce targeted value resolvers with `#[ValueResolver]` and `#[AsTargetedValueResolver]`
1414
* Add `#[MapRequestPayload]` to map and validate request payload from `Request::getContent()` or `Request::$request->all()` to typed objects
1515
* Add `#[MapQueryString]` to map and validate request query string from `Request::$query->all()` to typed objects
16+
* Add `#[MapUploadedFile]` attribute to fetch, validate, and inject uploaded files into controller arguments
1617

1718
6.2
1819
---

‎src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php
+18-10Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111

1212
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
1313

14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
1415
use Symfony\Component\HttpFoundation\Request;
1516
use Symfony\Component\HttpFoundation\Response;
1617
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
1718
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
19+
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
1820
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
1921
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
2022
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -62,21 +64,22 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
6264
$payloadMappers = [
6365
MapQueryString::class => ['mapQueryString', Response::HTTP_NOT_FOUND],
6466
MapRequestPayload::class => ['mapRequestPayload', Response::HTTP_UNPROCESSABLE_ENTITY],
67+
MapUploadedFile::class => ['mapUploadedFile', Response::HTTP_UNPROCESSABLE_ENTITY],
6568
];
6669

6770
foreach ($payloadMappers as $mappingAttribute => [$payloadMapper, $validationFailedCode]) {
6871
if (!$attributes = $argument->getAttributesOfType($mappingAttribute, ArgumentMetadata::IS_INSTANCEOF)) {
6972
continue;
7073
}
7174

72-
if (!$type = $argument->getType()) {
75+
if (!$argument->getType()) {
7376
throw new \LogicException(sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->getName()));
7477
}
7578

7679
if ($this->validator) {
7780
$violations = new ConstraintViolationList();
7881
try {
79-
$payload = $this->$payloadMapper($request, $type, $attributes[0]);
82+
$payload = $this->$payloadMapper($request, $argument, $attributes[0]);
8083
} catch (PartialDenormalizationException $e) {
8184
$trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p);
8285
foreach ($e->getErrors() as $error) {
@@ -92,41 +95,41 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
9295
}
9396

9497
if (null !== $payload) {
95-
$violations->addAll($this->validator->validate($payload));
98+
$violations->addAll($this->validator->validate($payload, $attributes[0]->constraints ?? null));
9699
}
97100

98101
if (\count($violations)) {
99102
throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations));
100103
}
101104
} else {
102105
try {
103-
$payload = $this->$payloadMapper($request, $type, $attributes[0]);
106+
$payload = $this->$payloadMapper($request, $argument, $attributes[0]);
104107
} catch (PartialDenormalizationException $e) {
105108
throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e);
106109
}
107110
}
108111

109112
if (null !== $payload || $argument->isNullable()) {
110-
return [$payload];
113+
return $argument->isVariadic() && \is_array($payload) ? $payload : [$payload];
111114
}
112115
}
113116

114117
return [];
115118
}
116119

117-
private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object
120+
private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object
118121
{
119122
if (!$data = $request->query->all()) {
120123
return null;
121124
}
122125

123-
return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->context);
126+
return $this->serializer->denormalize($data, $argument->getType(), null, self::CONTEXT_DENORMALIZE + $attribute->context);
124127
}
125128

126-
private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): ?object
129+
private function mapRequestPayload(Request $request, ArgumentMetadata $argument, MapRequestPayload $attribute): ?object
127130
{
128131
if ($data = $request->request->all()) {
129-
return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->context);
132+
return $this->serializer->denormalize($data, $argument->getType(), null, self::CONTEXT_DENORMALIZE + $attribute->context);
130133
}
131134

132135
if ('' === $data = $request->getContent()) {
@@ -142,11 +145,16 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay
142145
}
143146

144147
try {
145-
return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->context);
148+
return $this->serializer->deserialize($data, $argument->getType(), $format, self::CONTEXT_DESERIALIZE + $attribute->context);
146149
} catch (UnsupportedFormatException $e) {
147150
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e);
148151
} catch (NotEncodableValueException $e) {
149152
throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e);
150153
}
151154
}
155+
156+
private function mapUploadedFile(Request $request, ArgumentMetadata $argument, MapUploadedFile $attribute): UploadedFile|array|null
157+
{
158+
return $request->files->get($attribute->name ?? $argument->getName());
159+
}
152160
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.