Skip to content

Commit 068fb4d

Browse files
author
Renan
committed
[HttpKernel] Enhance MapRequestPayload adding format and validation group
apply feedbacks revert argument type better properties name attempt to fix CI static providers
1 parent d5423db commit 068fb4d

File tree

4 files changed

+206
-9
lines changed

4 files changed

+206
-9
lines changed

src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\HttpKernel\Attribute;
1313

1414
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
15+
use Symfony\Component\Validator\Constraints\GroupSequence;
1516

1617
/**
1718
* Controller parameter tag to map the request content to typed object and validate it.
@@ -22,7 +23,9 @@
2223
class MapRequestPayload extends ValueResolver
2324
{
2425
public function __construct(
25-
public readonly array $context = [],
26+
public readonly array|string|null $acceptFormat = null,
27+
public readonly array $serializationContext = [],
28+
public readonly string|GroupSequence|array|null $validationGroups = null,
2629
string $resolver = RequestPayloadValueResolver::class,
2730
) {
2831
parent::__construct($resolver);

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
9292
}
9393

9494
if (null !== $payload) {
95-
$violations->addAll($this->validator->validate($payload));
95+
$violations->addAll($this->validator->validate($payload, null, $attributes[0]->validationGroups ?? null));
9696
}
9797

9898
if (\count($violations)) {
@@ -125,24 +125,28 @@ private function mapQueryString(Request $request, string $type, MapQueryString $
125125

126126
private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): ?object
127127
{
128+
if (null === $format = $request->getContentTypeFormat()) {
129+
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, 'Unsupported format.');
130+
}
131+
132+
if ($attribute->acceptFormat && !\in_array($format, (array) $attribute->acceptFormat, true)) {
133+
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format));
134+
}
135+
128136
if ($data = $request->request->all()) {
129-
return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->context);
137+
return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->serializationContext);
130138
}
131139

132140
if ('' === $data = $request->getContent()) {
133141
return null;
134142
}
135143

136-
if (null === $format = $request->getContentTypeFormat()) {
137-
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, 'Unsupported format.');
138-
}
139-
140144
if ('form' === $format) {
141145
throw new HttpException(Response::HTTP_BAD_REQUEST, 'Request payload contains invalid "form" data.');
142146
}
143147

144148
try {
145-
return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->context);
149+
return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
146150
} catch (UnsupportedFormatException $e) {
147151
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e);
148152
} catch (NotEncodableValueException $e) {

src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
2020
use Symfony\Component\HttpKernel\Exception\HttpException;
2121
use Symfony\Component\Serializer\Encoder\JsonEncoder;
22+
use Symfony\Component\Serializer\Encoder\XmlEncoder;
2223
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
2324
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
2425
use Symfony\Component\Serializer\Serializer;
26+
use Symfony\Component\Validator\Constraints as Assert;
2527
use Symfony\Component\Validator\ConstraintViolation;
2628
use Symfony\Component\Validator\ConstraintViolationList;
2729
use Symfony\Component\Validator\Exception\ValidationFailedException;
2830
use Symfony\Component\Validator\Validator\ValidatorInterface;
31+
use Symfony\Component\Validator\ValidatorBuilder;
2932

3033
class RequestPayloadValueResolverTest extends TestCase
3134
{
@@ -181,10 +184,197 @@ public function testRequestInputValidationPassed()
181184

182185
$this->assertEquals($payload, $resolver->resolve($request, $argument)[0]);
183186
}
187+
188+
/**
189+
* @dataProvider provideMatchedFormatContext
190+
*/
191+
public function testAcceptFormatPassed(mixed $acceptFormat, string $contentType, string $content)
192+
{
193+
$encoders = ['json' => new JsonEncoder(), 'xml' => new XmlEncoder()];
194+
$serializer = new Serializer([new ObjectNormalizer()], $encoders);
195+
$validator = (new ValidatorBuilder())->getValidator();
196+
$resolver = new RequestPayloadValueResolver($serializer, $validator);
197+
198+
$request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => $contentType], content: $content);
199+
200+
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
201+
MapRequestPayload::class => new MapRequestPayload(acceptFormat: $acceptFormat),
202+
]);
203+
204+
$resolved = $resolver->resolve($request, $argument);
205+
206+
$this->assertCount(1, $resolved);
207+
$this->assertEquals(new RequestPayload(50), $resolved[0]);
208+
}
209+
210+
public static function provideMatchedFormatContext(): iterable
211+
{
212+
yield 'configure with json as string, sends json' => [
213+
'acceptFormat' => 'json',
214+
'contentType' => 'application/json',
215+
'content' => '{"price": 50}',
216+
];
217+
218+
yield 'configure with json as array, sends json' => [
219+
'acceptFormat' => ['json'],
220+
'contentType' => 'application/json',
221+
'content' => '{"price": 50}',
222+
];
223+
224+
yield 'configure with xml as string, sends xml' => [
225+
'acceptFormat' => 'xml',
226+
'contentType' => 'application/xml',
227+
'content' => '<?xml version="1.0"?><request><price>50</price></request>',
228+
];
229+
230+
yield 'configure with xml as array, sends xml' => [
231+
'acceptFormat' => ['xml'],
232+
'contentType' => 'application/xml',
233+
'content' => '<?xml version="1.0"?><request><price>50</price></request>',
234+
];
235+
236+
yield 'configure with json or xml, sends json' => [
237+
'acceptFormat' => ['json', 'xml'],
238+
'contentType' => 'application/json',
239+
'content' => '{"price": 50}',
240+
];
241+
242+
yield 'configure with json or xml, sends xml' => [
243+
'acceptFormat' => ['json', 'xml'],
244+
'contentType' => 'application/xml',
245+
'content' => '<?xml version="1.0"?><request><price>50</price></request>',
246+
];
247+
}
248+
249+
/**
250+
* @dataProvider provideMismatchedFormatContext
251+
*/
252+
public function testAcceptFormatNotPassed(mixed $acceptFormat, string $contentType, string $content, string $expectedExceptionMessage)
253+
{
254+
$serializer = new Serializer([new ObjectNormalizer()]);
255+
$validator = (new ValidatorBuilder())->getValidator();
256+
$resolver = new RequestPayloadValueResolver($serializer, $validator);
257+
258+
$request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => $contentType], content: $content);
259+
260+
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
261+
MapRequestPayload::class => new MapRequestPayload(acceptFormat: $acceptFormat),
262+
]);
263+
264+
try {
265+
$resolver->resolve($request, $argument);
266+
267+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
268+
} catch (HttpException $e) {
269+
$this->assertSame(415, $e->getStatusCode());
270+
$this->assertSame($expectedExceptionMessage, $e->getMessage());
271+
}
272+
}
273+
274+
public static function provideMismatchedFormatContext(): iterable
275+
{
276+
yield 'configure with json as string, sends xml' => [
277+
'acceptFormat' => 'json',
278+
'contentType' => 'application/xml',
279+
'content' => '<?xml version="1.0"?><request><price>50</price></request>',
280+
'expectedExceptionMessage' => 'Unsupported format, expects "json", but "xml" given.',
281+
];
282+
283+
yield 'configure with json as array, sends xml' => [
284+
'acceptFormat' => ['json'],
285+
'contentType' => 'application/xml',
286+
'content' => '<?xml version="1.0"?><request><price>50</price></request>',
287+
'expectedExceptionMessage' => 'Unsupported format, expects "json", but "xml" given.',
288+
];
289+
290+
yield 'configure with xml as string, sends json' => [
291+
'acceptFormat' => 'xml',
292+
'contentType' => 'application/json',
293+
'content' => '{"price": 50}',
294+
'expectedExceptionMessage' => 'Unsupported format, expects "xml", but "json" given.',
295+
];
296+
297+
yield 'configure with xml as array, sends json' => [
298+
'acceptFormat' => ['xml'],
299+
'contentType' => 'application/json',
300+
'content' => '{"price": 50}',
301+
'expectedExceptionMessage' => 'Unsupported format, expects "xml", but "json" given.',
302+
];
303+
304+
yield 'configure with json or xml, sends jsonld' => [
305+
'acceptFormat' => ['json', 'xml'],
306+
'contentType' => 'application/ld+json',
307+
'content' => '{"@context": "https://schema.org", "@type": "FakeType", "price": 50}',
308+
'expectedExceptionMessage' => 'Unsupported format, expects "json", "xml", but "jsonld" given.',
309+
];
310+
}
311+
312+
/**
313+
* @dataProvider provideValidationGroupsOnManyTypes
314+
*/
315+
public function testValidationGroupsPassed(mixed $groups)
316+
{
317+
$input = ['price' => '50', 'title' => 'A long title, so the validation passes'];
318+
319+
$payload = new RequestPayload(50);
320+
$payload->title = 'A long title, so the validation passes';
321+
322+
$serializer = new Serializer([new ObjectNormalizer()]);
323+
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
324+
$resolver = new RequestPayloadValueResolver($serializer, $validator);
325+
326+
$request = Request::create('/', 'POST', $input);
327+
328+
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
329+
MapRequestPayload::class => new MapRequestPayload(validationGroups: $groups),
330+
]);
331+
332+
$resolved = $resolver->resolve($request, $argument);
333+
334+
$this->assertCount(1, $resolved);
335+
$this->assertEquals($payload, $resolved[0]);
336+
}
337+
338+
/**
339+
* @dataProvider provideValidationGroupsOnManyTypes
340+
*/
341+
public function testValidationGroupsNotPassed(mixed $groups)
342+
{
343+
$input = ['price' => '50', 'title' => 'Too short'];
344+
345+
$serializer = new Serializer([new ObjectNormalizer()]);
346+
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
347+
$resolver = new RequestPayloadValueResolver($serializer, $validator);
348+
349+
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
350+
MapRequestPayload::class => new MapRequestPayload(validationGroups: $groups),
351+
]);
352+
$request = Request::create('/', 'POST', $input);
353+
354+
try {
355+
$resolver->resolve($request, $argument);
356+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
357+
} catch (HttpException $e) {
358+
$validationFailedException = $e->getPrevious();
359+
$this->assertInstanceOf(ValidationFailedException::class, $validationFailedException);
360+
$this->assertSame('title', $validationFailedException->getViolations()[0]->getPropertyPath());
361+
$this->assertSame('This value is too short. It should have 10 characters or more.', $validationFailedException->getViolations()[0]->getMessage());
362+
}
363+
}
364+
365+
public static function provideValidationGroupsOnManyTypes(): iterable
366+
{
367+
yield 'validation group as string' => ['strict'];
368+
369+
yield 'validation group as array' => [['strict']];
370+
371+
yield 'validation group as GroupSequence' => [new Assert\GroupSequence(['strict'])];
372+
}
184373
}
185374

186375
class RequestPayload
187376
{
377+
#[Assert\Length(min: 10, groups: ['strict'])]
188378
public string $title;
189379

190380
public function __construct(public readonly float $price)

src/Symfony/Component/HttpKernel/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"symfony/translation": "^5.4|^6.0",
4444
"symfony/translation-contracts": "^2.5|^3",
4545
"symfony/uid": "^5.4|^6.0",
46-
"symfony/validator": "^5.4|^6.0",
46+
"symfony/validator": "^6.3",
4747
"psr/cache": "^1.0|^2.0|^3.0",
4848
"twig/twig": "^2.13|^3.0.4"
4949
},

0 commit comments

Comments
 (0)