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 a7a403c

Browse filesBrowse files
committed
[HttpKernel] Enhance MapRequestPayload adding format and validation group
apply feedbacks revert argument type better properties name
1 parent d5423db commit a7a403c
Copy full SHA for a7a403c

File tree

3 files changed

+205
-8
lines changed
Filter options

3 files changed

+205
-8
lines changed

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

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php
+4-1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php
+11-7Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php
+190Lines 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;
2526
use Symfony\Component\Validator\ConstraintViolation;
2627
use Symfony\Component\Validator\ConstraintViolationList;
28+
use Symfony\Component\Validator\Constraints as Assert;
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 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 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 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)

0 commit comments

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