Skip to content

Commit 55880a5

Browse files
committed
#[MapRequestPayload][Serializer] improve nested payload validation for MapRequestPayload using a new serialization context
1 parent aeb2489 commit 55880a5

File tree

3 files changed

+106
-8
lines changed

3 files changed

+106
-8
lines changed

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

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
2222
use Symfony\Component\HttpKernel\Exception\HttpException;
2323
use Symfony\Component\HttpKernel\HttpKernelInterface;
24-
use Symfony\Component\PropertyAccess\Exception\InvalidTypeException;
2524
use Symfony\Component\Serializer\Encoder\JsonEncoder;
2625
use Symfony\Component\Serializer\Encoder\XmlEncoder;
2726
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
@@ -285,6 +284,70 @@ public function testValidationNotPerformedWhenPartialDenormalizationReturnsViola
285284
}
286285
}
287286

287+
public function testNestedPayloadErrorReportingWhenPartialDenormalizationReturnsViolation()
288+
{
289+
$content = '{
290+
"name": "john doe",
291+
"address": {
292+
"address": "2332 street",
293+
"zipcode": "20220",
294+
"city": "Paris",
295+
"country": "75000",
296+
"geolocalization": {
297+
"lng": 32.423
298+
}
299+
}
300+
}';
301+
$serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]);
302+
303+
$validator = $this->createMock(ValidatorInterface::class);
304+
$validator->expects($this->never())
305+
->method('validate');
306+
307+
$resolver = new RequestPayloadValueResolver($serializer, $validator);
308+
$request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => 'application/json'], content: $content);
309+
$kernel = $this->createMock(HttpKernelInterface::class);
310+
311+
312+
// Test using use_class_as_default_expected_type = false context
313+
$argument = new ArgumentMetadata('invalid-nested-payload', Employee::class, false, false, null, false, [
314+
MapRequestPayload::class => new MapRequestPayload(serializationContext: ['use_class_as_default_expected_type' => false]),
315+
]);
316+
$arguments = $resolver->resolve($request, $argument);
317+
$event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);
318+
319+
try {
320+
$resolver->onKernelControllerArguments($event);
321+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
322+
} catch (HttpException $e) {
323+
$validationFailedException = $e->getPrevious();
324+
$this->assertInstanceOf(ValidationFailedException::class, $validationFailedException);
325+
$this->assertSame(
326+
sprintf('This value should be of type %s.', 'unknown'),
327+
$validationFailedException->getViolations()[0]->getMessage()
328+
);
329+
}
330+
331+
// Test using use_class_as_default_expected_type context
332+
$argument = new ArgumentMetadata('invalid-nested-payload', Employee::class, false, false, null, false, [
333+
MapRequestPayload::class => new MapRequestPayload(serializationContext: ['use_class_as_default_expected_type' => true]),
334+
]);
335+
$arguments = $resolver->resolve($request, $argument);
336+
$event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);
337+
338+
try {
339+
$resolver->onKernelControllerArguments($event);
340+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
341+
} catch (HttpException $e) {
342+
$validationFailedException = $e->getPrevious();
343+
$this->assertInstanceOf(ValidationFailedException::class, $validationFailedException);
344+
$this->assertSame(
345+
sprintf('This value should be of type %s.', Geolocalization::class),
346+
$validationFailedException->getViolations()[0]->getMessage()
347+
);
348+
}
349+
}
350+
288351
public function testUnsupportedMedia()
289352
{
290353
$serializer = new Serializer();
@@ -731,3 +794,34 @@ public function getPassword(): string
731794
return $this->password;
732795
}
733796
}
797+
798+
class Employee
799+
{
800+
public function __construct(
801+
public string $name,
802+
#[Assert\Valid]
803+
public ?Address $address = null,
804+
) {
805+
}
806+
}
807+
808+
class Address
809+
{
810+
public function __construct(
811+
public string $address,
812+
public string $zipcode,
813+
public string $city,
814+
public string $country,
815+
public Geolocalization $geolocalization,
816+
) {
817+
}
818+
}
819+
820+
class Geolocalization
821+
{
822+
public function __construct(
823+
public string $lat,
824+
public string $lng,
825+
) {
826+
}
827+
}

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
CHANGELOG
22
=========
33

4+
7.1
5+
---
6+
* Add `AbstractNormalizer::USE_CLASS_AS_DEFAULT_EXPECTED_TYPE` in order to use the FQCN as the default value for NotNormalizableValueException's expectedTypes instead of unknown
7+
48
7.0
59
---
610

src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn
118118
*/
119119
public const REQUIRE_ALL_PROPERTIES = 'require_all_properties';
120120

121+
/**
122+
* Use class name as default expected type when throwing NotNormalizableValueException instead of unknown.
123+
*/
124+
public const USE_CLASS_AS_DEFAULT_EXPECTED_TYPE = 'use_class_as_default_expected_type';
125+
121126
/**
122127
* @internal
123128
*/
@@ -380,7 +385,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex
380385
$exception = NotNormalizableValueException::createForUnexpectedDataType(
381386
sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name),
382387
$data,
383-
['unknown'],
388+
[isset($context[self::USE_CLASS_AS_DEFAULT_EXPECTED_TYPE]) && $context[self::USE_CLASS_AS_DEFAULT_EXPECTED_TYPE] ? $class : 'unknown'],
384389
$context['deserialization_path'] ?? null,
385390
true
386391
);
@@ -424,12 +429,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex
424429
unset($context['has_constructor']);
425430

426431
if (!$reflectionClass->isInstantiable()) {
427-
throw NotNormalizableValueException::createForUnexpectedDataType(
428-
sprintf('Failed to create object because the class "%s" is not instantiable.', $class),
429-
$data,
430-
['unknown'],
431-
$context['deserialization_path'] ?? null
432-
);
432+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Failed to create object because the class "%s" is not instantiable.', $class), $data, ['unknown'], $context['deserialization_path'] ?? null);
433433
}
434434

435435
return new $class();

0 commit comments

Comments
 (0)