Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b0fbe93

Browse files
committed
feature #42502 [Serializer] Add support for collecting type error during denormalization (lyrixx)
This PR was merged into the 5.4 branch. Discussion ---------- [Serializer] Add support for collecting type error during denormalization | Q | A | ------------- | --- | Branch? | 5.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #27824, Fix #42236, Fix #38472, Fix #37419 Fix #38968 | License | MIT | Doc PR | --- There is something that I don't like about the (de)Serializer. It's about the way it deals with typed properties. As soon as you add a type to a property, the API can return 500. Let's consider the following code: ```php class MyDto { public string $string; public int $int; public float $float; public bool $bool; public \DateTime $dateTime; public \DateTimeImmutable $dateTimeImmutable; public \DateTimeZone $dateTimeZone; public \SplFileInfo $splFileInfo; public Uuid $uuid; public array $array; /** `@var` MyDto[] */ public array $collection; } ``` and the following JSON: ```json { "string": null, "int": null, "float": null, "bool": null, "dateTime": null, "dateTimeImmutable": null, "dateTimeZone": null, "splFileInfo": null, "uuid": null, "array": null, "collection": [ { "string": "string" }, { "string": null } ] } ``` **By default**, I got a 500: ![image](https://user-images.githubusercontent.com/408368/129211588-0ce9064e-171d-42f2-89ac-b126fc3f9eab.png) It's the same with the prod environment. This is far from perfect when you try to make a public API :/ ATM, the only solution, is to remove all typehints and add assertions (validator component). With that, the public API is nice, but the internal PHP is not so good (PHP 7.4+ FTW!) In APIP, they have support for transforming to [something](https://github.com/api-platform/core/blob/53837eee3ebdea861ffc1c9c7f052eecca114757/src/Core/Serializer/AbstractItemNormalizer.php#L233-L237) they can handle gracefully. But the deserialization stop on the first error (so the end user must fix the error, try again, fix the second error, try again etc.). And the raw exception message is leaked to the end user. So the API can return something like `The type of the "string" attribute for class "App\Dto\MyDto" must be one of "string" ("null" given).`. Really not cool :/ So ATM, building a nice public API is not cool. That's why I propose this PR that address all issues reported * be able to collect all error * with their property path associated * don't leak anymore internal In order to not break the BC, I had to use some fancy code to make it work 🐒 With the following code, I'm able to collect all errors, transform them in `ConstraintViolationList` and render them properly, as expected. ![image](https://user-images.githubusercontent.com/408368/129215560-b0254a4e-fec7-4422-bee0-95cf9f9eda6c.png) ```php #[Route('/api', methods:['POST'])] public function apiPost(SerializerInterface $serializer, Request $request): Response { $context = ['not_normalizable_value_exceptions' => []]; $exceptions = &$context['not_normalizable_value_exceptions']; $dto = $serializer->deserialize($request->getContent(), MyDto::class, 'json', $context); if ($exceptions) { $violations = new ConstraintViolationList(); /** `@var` NotNormalizableValueException */ foreach ($exceptions as $exception) { $message = sprintf('The type must be one of "%s" ("%s" given).', implode(', ', $exception->getExpectedTypes()), $exception->getCurrentType()); $parameters = []; if ($exception->canUseMessageForUser()) { $parameters['hint'] = $exception->getMessage(); } $violations->add(new ConstraintViolation($message, '', $parameters, null, $exception->getPath(), null)); }; return $this->json($violations, 400); } return $this->json($dto); } ``` If this PR got accepted, the above code could be transferred to APIP to handle correctly the deserialization Commits ------- ebe6551 [Serializer] Add support for collecting type error during denormalization
2 parents db76265 + ebe6551 commit b0fbe93

15 files changed

+465
-23
lines changed

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add support of PHP backed enumerations
88
* Add support for serializing empty array as object
99
* Return empty collections as `ArrayObject` from `Serializer::normalize()` when `PRESERVE_EMPTY_OBJECTS` is set
10+
* Add support for collecting type errors during denormalization
1011

1112
5.3
1213
---

src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,48 @@
1616
*/
1717
class NotNormalizableValueException extends UnexpectedValueException
1818
{
19+
private $currentType;
20+
private $expectedTypes;
21+
private $path;
22+
private $useMessageForUser = false;
23+
24+
/**
25+
* @param bool $useMessageForUser If the message passed to this exception is something that can be shown
26+
* safely to your user. In other words, avoid catching other exceptions and
27+
* passing their message directly to this class.
28+
*/
29+
public static function createForUnexpectedDataType(string $message, $data, array $expectedTypes, string $path = null, bool $useMessageForUser = false, int $code = 0, \Throwable $previous = null): self
30+
{
31+
$self = new self($message, $code, $previous);
32+
33+
$self->currentType = get_debug_type($data);
34+
$self->expectedTypes = $expectedTypes;
35+
$self->path = $path;
36+
$self->useMessageForUser = $useMessageForUser;
37+
38+
return $self;
39+
}
40+
41+
public function getCurrentType(): ?string
42+
{
43+
return $this->currentType;
44+
}
45+
46+
/**
47+
* @return string[]|null
48+
*/
49+
public function getExpectedTypes(): ?array
50+
{
51+
return $this->expectedTypes;
52+
}
53+
54+
public function getPath(): ?string
55+
{
56+
return $this->path;
57+
}
58+
59+
public function canUseMessageForUser(): ?bool
60+
{
61+
return $this->useMessageForUser;
62+
}
1963
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Serializer\Exception;
13+
14+
/**
15+
* @author Grégoire Pineau <[email protected]>
16+
*/
17+
class PartialDenormalizationException extends UnexpectedValueException
18+
{
19+
private $data;
20+
private $errors;
21+
22+
public function __construct($data, array $errors)
23+
{
24+
$this->data = $data;
25+
$this->errors = $errors;
26+
}
27+
28+
public function getData()
29+
{
30+
return $this->data;
31+
}
32+
33+
public function getErrors(): array
34+
{
35+
return $this->errors;
36+
}
37+
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1616
use Symfony\Component\Serializer\Exception\LogicException;
1717
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
18+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1819
use Symfony\Component\Serializer\Exception\RuntimeException;
1920
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
2021
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
@@ -399,7 +400,20 @@ protected function instantiateObject(array &$data, string $class, array &$contex
399400
} elseif ($constructorParameter->hasType() && $constructorParameter->getType()->allowsNull()) {
400401
$params[] = null;
401402
} else {
402-
throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name));
403+
if (!isset($context['not_normalizable_value_exceptions'])) {
404+
throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name));
405+
}
406+
407+
$exception = NotNormalizableValueException::createForUnexpectedDataType(
408+
sprintf('Failed to create object because the object miss the "%s" property.', $constructorParameter->name),
409+
$data,
410+
['unknown'],
411+
$context['deserialization_path'] ?? null,
412+
true
413+
);
414+
$context['not_normalizable_value_exceptions'][] = $exception;
415+
416+
return $reflectionClass->newInstanceWithoutConstructor();
403417
}
404418
}
405419

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ private function getAttributeDenormalizationContext(string $class, string $attri
255255
return $context;
256256
}
257257

258+
$context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute;
259+
258260
return array_merge($context, $metadata->getDenormalizationContextForGroups($this->getGroups($context)));
259261
}
260262

@@ -391,12 +393,33 @@ public function denormalize($data, string $type, string $format = null, array $c
391393
$types = $this->getTypes($resolvedClass, $attribute);
392394

393395
if (null !== $types) {
394-
$value = $this->validateAndDenormalize($types, $resolvedClass, $attribute, $value, $format, $attributeContext);
396+
try {
397+
$value = $this->validateAndDenormalize($types, $resolvedClass, $attribute, $value, $format, $attributeContext);
398+
} catch (NotNormalizableValueException $exception) {
399+
if (isset($context['not_normalizable_value_exceptions'])) {
400+
$context['not_normalizable_value_exceptions'][] = $exception;
401+
continue;
402+
}
403+
throw $exception;
404+
}
395405
}
396406
try {
397407
$this->setAttributeValue($object, $attribute, $value, $format, $attributeContext);
398408
} catch (InvalidArgumentException $e) {
399-
throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e);
409+
$exception = NotNormalizableValueException::createForUnexpectedDataType(
410+
sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type),
411+
$data,
412+
['unknown'],
413+
$context['deserialization_path'] ?? null,
414+
false,
415+
$e->getCode(),
416+
$e
417+
);
418+
if (isset($context['not_normalizable_value_exceptions'])) {
419+
$context['not_normalizable_value_exceptions'][] = $exception;
420+
continue;
421+
}
422+
throw $exception;
400423
}
401424
}
402425

@@ -455,14 +478,14 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
455478
} elseif ('true' === $data || '1' === $data) {
456479
$data = true;
457480
} else {
458-
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data));
481+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
459482
}
460483
break;
461484
case Type::BUILTIN_TYPE_INT:
462485
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
463486
$data = (int) $data;
464487
} else {
465-
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data));
488+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
466489
}
467490
break;
468491
case Type::BUILTIN_TYPE_FLOAT:
@@ -478,7 +501,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
478501
case '-INF':
479502
return -\INF;
480503
default:
481-
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data));
504+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null);
482505
}
483506

484507
break;
@@ -549,7 +572,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
549572
return $data;
550573
}
551574

552-
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), get_debug_type($data)));
575+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), get_debug_type($data)), $data, array_keys($expectedTypes), $context['deserialization_path'] ?? null);
553576
}
554577

555578
/**

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@ public function denormalize($data, string $type, string $format = null, array $c
5050

5151
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
5252
foreach ($data as $key => $value) {
53+
$subContext = $context;
54+
$subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]";
55+
5356
if (null !== $builtinType && !('is_'.$builtinType)($key)) {
54-
throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)));
57+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)), $key, [$builtinType], $subContext['deserialization_path'] ?? null, true);
5558
}
5659

57-
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $context);
60+
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
5861
}
5962

6063
return $data;

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Normalizer;
1313

14+
use Symfony\Component\PropertyInfo\Type;
1415
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1516
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1617

@@ -57,13 +58,13 @@ public function denormalize($data, $type, $format = null, array $context = [])
5758
}
5859

5960
if (!\is_int($data) && !\is_string($data)) {
60-
throw new NotNormalizableValueException('The data is neither an integer nor a string, you should pass an integer or a string that can be parsed as an enumeration case of type '.$type.'.');
61+
throw NotNormalizableValueException::createForUnexpectedDataType('The data is neither an integer nor a string, you should pass an integer or a string that can be parsed as an enumeration case of type '.$type.'.', $data, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
6162
}
6263

6364
try {
6465
return $type::from($data);
6566
} catch (\ValueError $e) {
66-
throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
67+
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
6768
}
6869
}
6970

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ public function supportsNormalization($data, string $format = null)
9696
public function denormalize($data, string $type, string $format = null, array $context = [])
9797
{
9898
if (!preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) {
99-
throw new NotNormalizableValueException('The provided "data:" URI is not valid.');
99+
throw NotNormalizableValueException::createForUnexpectedDataType('The provided "data:" URI is not valid.', $data, ['string'], $context['deserialization_path'] ?? null, true);
100100
}
101101

102102
try {
@@ -113,7 +113,7 @@ public function denormalize($data, string $type, string $format = null, array $c
113113
return new \SplFileObject($data);
114114
}
115115
} catch (\RuntimeException $exception) {
116-
throw new NotNormalizableValueException($exception->getMessage(), $exception->getCode(), $exception);
116+
throw NotNormalizableValueException::createForUnexpectedDataType($exception->getMessage(), $data, ['string'], $context['deserialization_path'] ?? null, false, $exception->getCode(), $exception);
117117
}
118118

119119
throw new InvalidArgumentException(sprintf('The class parameter "%s" is not supported. It must be one of "SplFileInfo", "SplFileObject" or "Symfony\Component\HttpFoundation\File\File".', $type));

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Normalizer;
1313

14+
use Symfony\Component\PropertyInfo\Type;
1415
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1516
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1617

@@ -86,7 +87,7 @@ public function denormalize($data, string $type, string $format = null, array $c
8687
$timezone = $this->getTimezone($context);
8788

8889
if (null === $data || (\is_string($data) && '' === trim($data))) {
89-
throw new NotNormalizableValueException('The data is either an empty string or null, you should pass a string that can be parsed with the passed format or a valid DateTime string.');
90+
throw NotNormalizableValueException::createForUnexpectedDataType('The data is either an empty string or null, you should pass a string that can be parsed with the passed format or a valid DateTime string.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
9091
}
9192

9293
if (null !== $dateTimeFormat) {
@@ -98,13 +99,13 @@ public function denormalize($data, string $type, string $format = null, array $c
9899

99100
$dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors();
100101

101-
throw new NotNormalizableValueException(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])));
102+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
102103
}
103104

104105
try {
105106
return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone);
106107
} catch (\Exception $e) {
107-
throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
108+
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, false, $e->getCode(), $e);
108109
}
109110
}
110111

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Normalizer;
1313

14+
use Symfony\Component\PropertyInfo\Type;
1415
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1516
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1617

@@ -55,13 +56,13 @@ public function supportsNormalization($data, string $format = null)
5556
public function denormalize($data, string $type, string $format = null, array $context = [])
5657
{
5758
if ('' === $data || null === $data) {
58-
throw new NotNormalizableValueException('The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.');
59+
throw NotNormalizableValueException::createForUnexpectedDataType('The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
5960
}
6061

6162
try {
6263
return new \DateTimeZone($data);
6364
} catch (\Exception $e) {
64-
throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
65+
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
6566
}
6667
}
6768

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
*/
2525
interface DenormalizerInterface
2626
{
27+
public const COLLECT_DENORMALIZATION_ERRORS = 'collect_denormalization_errors';
28+
2729
/**
2830
* Denormalizes data back into an object of the given class.
2931
*

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Normalizer;
1313

14+
use Symfony\Component\PropertyInfo\Type;
1415
use Symfony\Component\Serializer\Exception\LogicException;
1516
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1617
use Symfony\Component\Uid\AbstractUid;
@@ -72,7 +73,9 @@ public function denormalize($data, string $type, string $format = null, array $c
7273
try {
7374
return Ulid::class === $type ? Ulid::fromString($data) : Uuid::fromString($data);
7475
} catch (\InvalidArgumentException $exception) {
75-
throw new NotNormalizableValueException(sprintf('The data is not a valid "%s" string representation.', $type));
76+
throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
77+
} catch (\TypeError $exception) {
78+
throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
7679
}
7780
}
7881

0 commit comments

Comments
 (0)