diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index e54c22996167f..be9f305af61d0 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add support of PHP backed enumerations * Add support for serializing empty array as object * Return empty collections as `ArrayObject` from `Serializer::normalize()` when `PRESERVE_EMPTY_OBJECTS` is set + * Add support for collecting type errors during denormalization 5.3 --- diff --git a/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php b/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php index 58adf72cab147..e601e5043e2e9 100644 --- a/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php +++ b/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php @@ -16,4 +16,48 @@ */ class NotNormalizableValueException extends UnexpectedValueException { + private $currentType; + private $expectedTypes; + private $path; + private $useMessageForUser = false; + + /** + * @param bool $useMessageForUser If the message passed to this exception is something that can be shown + * safely to your user. In other words, avoid catching other exceptions and + * passing their message directly to this class. + */ + public static function createForUnexpectedDataType(string $message, $data, array $expectedTypes, string $path = null, bool $useMessageForUser = false, int $code = 0, \Throwable $previous = null): self + { + $self = new self($message, $code, $previous); + + $self->currentType = get_debug_type($data); + $self->expectedTypes = $expectedTypes; + $self->path = $path; + $self->useMessageForUser = $useMessageForUser; + + return $self; + } + + public function getCurrentType(): ?string + { + return $this->currentType; + } + + /** + * @return string[]|null + */ + public function getExpectedTypes(): ?array + { + return $this->expectedTypes; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function canUseMessageForUser(): ?bool + { + return $this->useMessageForUser; + } } diff --git a/src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php b/src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php new file mode 100644 index 0000000000000..fdb838be79cae --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +/** + * @author Grégoire Pineau + */ +class PartialDenormalizationException extends UnexpectedValueException +{ + private $data; + private $errors; + + public function __construct($data, array $errors) + { + $this->data = $data; + $this->errors = $errors; + } + + public function getData() + { + return $this->data; + } + + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index a8698baaf0b43..c6d2f83b64306 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; @@ -399,7 +400,20 @@ protected function instantiateObject(array &$data, string $class, array &$contex } elseif ($constructorParameter->hasType() && $constructorParameter->getType()->allowsNull()) { $params[] = null; } else { - 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)); + if (!isset($context['not_normalizable_value_exceptions'])) { + 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)); + } + + $exception = NotNormalizableValueException::createForUnexpectedDataType( + sprintf('Failed to create object because the object miss the "%s" property.', $constructorParameter->name), + $data, + ['unknown'], + $context['deserialization_path'] ?? null, + true + ); + $context['not_normalizable_value_exceptions'][] = $exception; + + return $reflectionClass->newInstanceWithoutConstructor(); } } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index bc64490ca79cf..473578a02d015 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -239,6 +239,8 @@ private function getAttributeDenormalizationContext(string $class, string $attri return $context; } + $context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute; + return array_merge($context, $metadata->getDenormalizationContextForGroups($this->getGroups($context))); } @@ -375,12 +377,33 @@ public function denormalize($data, string $type, string $format = null, array $c $types = $this->getTypes($resolvedClass, $attribute); if (null !== $types) { - $value = $this->validateAndDenormalize($types, $resolvedClass, $attribute, $value, $format, $attributeContext); + try { + $value = $this->validateAndDenormalize($types, $resolvedClass, $attribute, $value, $format, $attributeContext); + } catch (NotNormalizableValueException $exception) { + if (isset($context['not_normalizable_value_exceptions'])) { + $context['not_normalizable_value_exceptions'][] = $exception; + continue; + } + throw $exception; + } } try { $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); } catch (InvalidArgumentException $e) { - throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e); + $exception = NotNormalizableValueException::createForUnexpectedDataType( + sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), + $data, + ['unknown'], + $context['deserialization_path'] ?? null, + false, + $e->getCode(), + $e + ); + if (isset($context['not_normalizable_value_exceptions'])) { + $context['not_normalizable_value_exceptions'][] = $exception; + continue; + } + throw $exception; } } @@ -439,14 +462,14 @@ private function validateAndDenormalize(array $types, string $currentClass, stri } elseif ('true' === $data || '1' === $data) { $data = true; } else { - throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data)); + 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); } break; case Type::BUILTIN_TYPE_INT: if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) { $data = (int) $data; } else { - throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data)); + 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); } break; case Type::BUILTIN_TYPE_FLOAT: @@ -462,7 +485,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri case '-INF': return -\INF; default: - throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data)); + 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); } break; @@ -533,7 +556,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri return $data; } - 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))); + 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); } /** diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index d26027fa02f2b..3c64eead1c8b9 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -50,11 +50,14 @@ public function denormalize($data, string $type, string $format = null, array $c $builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null; foreach ($data as $key => $value) { + $subContext = $context; + $subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]"; + if (null !== $builtinType && !('is_'.$builtinType)($key)) { - throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key))); + 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); } - $data[$key] = $this->denormalizer->denormalize($value, $type, $format, $context); + $data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext); } return $data; diff --git a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php index b129bb6a81f03..dbb9c89ab5e74 100644 --- a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -57,13 +58,13 @@ public function denormalize($data, $type, $format = null, array $context = []) } if (!\is_int($data) && !\is_string($data)) { - 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.'.'); + 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); } try { return $type::from($data); } catch (\ValueError $e) { - throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } } diff --git a/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php index bb866ec9bcc36..6dea963e656fa 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php @@ -96,7 +96,7 @@ public function supportsNormalization($data, string $format = null) public function denormalize($data, string $type, string $format = null, array $context = []) { 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)) { - throw new NotNormalizableValueException('The provided "data:" URI is not valid.'); + throw NotNormalizableValueException::createForUnexpectedDataType('The provided "data:" URI is not valid.', $data, ['string'], $context['deserialization_path'] ?? null, true); } try { @@ -113,7 +113,7 @@ public function denormalize($data, string $type, string $format = null, array $c return new \SplFileObject($data); } } catch (\RuntimeException $exception) { - throw new NotNormalizableValueException($exception->getMessage(), $exception->getCode(), $exception); + throw NotNormalizableValueException::createForUnexpectedDataType($exception->getMessage(), $data, ['string'], $context['deserialization_path'] ?? null, false, $exception->getCode(), $exception); } 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)); diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php index 19f9efdc0840a..82b9b70deb2ac 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -86,7 +87,7 @@ public function denormalize($data, string $type, string $format = null, array $c $timezone = $this->getTimezone($context); if (null === $data || (\is_string($data) && '' === trim($data))) { - 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.'); + 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); } if (null !== $dateTimeFormat) { @@ -98,13 +99,13 @@ public function denormalize($data, string $type, string $format = null, array $c $dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors(); - 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']))); + 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); } try { return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone); } catch (\Exception $e) { - throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); } } diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php index af262ebaad70e..e7b6665d6fcfd 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -55,13 +56,13 @@ public function supportsNormalization($data, string $format = null) public function denormalize($data, string $type, string $format = null, array $context = []) { if ('' === $data || null === $data) { - throw new NotNormalizableValueException('The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.'); + 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); } try { return new \DateTimeZone($data); } catch (\Exception $e) { - throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } } diff --git a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php index d903b3912d019..5b7d7f2288fb9 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php +++ b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php @@ -24,6 +24,8 @@ */ interface DenormalizerInterface { + public const COLLECT_DENORMALIZATION_ERRORS = 'collect_denormalization_errors'; + /** * Denormalizes data back into an object of the given class. * diff --git a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php index 55508a962df4f..e2fe96c978129 100644 --- a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Uid\AbstractUid; @@ -72,7 +73,9 @@ public function denormalize($data, string $type, string $format = null, array $c try { return Ulid::class === $type ? Ulid::fromString($data) : Uuid::fromString($data); } catch (\InvalidArgumentException $exception) { - throw new NotNormalizableValueException(sprintf('The data is not a valid "%s" string representation.', $type)); + throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + } catch (\TypeError $exception) { + throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); } } diff --git a/src/Symfony/Component/Serializer/Serializer.php b/src/Symfony/Component/Serializer/Serializer.php index dc22cb0d7af78..8ab8430f2c20d 100644 --- a/src/Symfony/Component/Serializer/Serializer.php +++ b/src/Symfony/Component/Serializer/Serializer.php @@ -21,6 +21,7 @@ use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; @@ -199,12 +200,16 @@ public function normalize($data, string $format = null, array $context = []) */ public function denormalize($data, string $type, string $format = null, array $context = []) { + if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS], $context['not_normalizable_value_exceptions'])) { + throw new LogicException('Passing a value for "not_normalizable_value_exceptions" context key is not allowed.'); + } + $normalizer = $this->getDenormalizer($data, $type, $format, $context); // Check for a denormalizer first, e.g. the data is wrapped if (!$normalizer && isset(self::SCALAR_TYPES[$type])) { if (!('is_'.$type)($data)) { - throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data))); + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)), $data, [$type], $context['deserialization_path'] ?? null, true); } return $data; @@ -214,11 +219,23 @@ public function denormalize($data, string $type, string $format = null, array $c throw new LogicException('You must register at least one normalizer to be able to denormalize objects.'); } - if ($normalizer) { - return $normalizer->denormalize($data, $type, $format, $context); + if (!$normalizer) { + throw new NotNormalizableValueException(sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type)); + } + + if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])) { + unset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]); + $context['not_normalizable_value_exceptions'] = []; + $errors = &$context['not_normalizable_value_exceptions']; + $denormalized = $normalizer->denormalize($data, $type, $format, $context); + if ($errors) { + throw new PartialDenormalizationException($denormalized, $errors); + } + + return $denormalized; } - throw new NotNormalizableValueException(sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type)); + return $normalizer->denormalize($data, $type, $format, $context); } /** diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Php74Full.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Php74Full.php new file mode 100644 index 0000000000000..496f722af925c --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Php74Full.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures; + +use Symfony\Component\Uid\Uuid; + +final class Php74Full +{ + 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 Php74Full[] */ + public array $collection; + public Php74FullWithConstructor $php74FullWithConstructor; +} + + +final class Php74FullWithConstructor +{ + public function __construct($constructorArgument) + { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 93ea7a8ab59e5..559c1037debfe 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -16,12 +16,14 @@ use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; @@ -34,6 +36,9 @@ use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; +use Symfony\Component\Serializer\Normalizer\DataUriNormalizer; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer; @@ -41,6 +46,7 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; +use Symfony\Component\Serializer\Normalizer\UidNormalizer; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -52,6 +58,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberTwo; use Symfony\Component\Serializer\Tests\Fixtures\NormalizableTraversableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Php74Full; use Symfony\Component\Serializer\Tests\Fixtures\TraversableDummy; use Symfony\Component\Serializer\Tests\Normalizer\TestDenormalizer; use Symfony\Component\Serializer\Tests\Normalizer\TestNormalizer; @@ -713,6 +720,255 @@ public function testDeserializeAndUnwrap() $serializer->deserialize($jsonData, __NAMESPACE__.'\Model', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]']) ); } + + /** @requires PHP 7.4 */ + public function testCollectDenormalizationErrors() + { + $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 + } + ], + "php74FullWithConstructor": {} + }'; + + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + + $serializer = new Serializer( + [ + new ArrayDenormalizer(), + new DateTimeNormalizer(), + new DateTimeZoneNormalizer(), + new DataUriNormalizer(), + new UidNormalizer(), + new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)), + ], + ['json' => new JsonEncoder()] + ); + + try { + $serializer->deserialize($json, Php74Full::class, 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + + $this->fail(); + } catch (\Throwable $th) { + $this->assertInstanceOf(PartialDenormalizationException::class, $th); + } + + $this->assertInstanceOf(Php74Full::class, $th->getData()); + + $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + return [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ]; + }, $th->getErrors()); + + $expected = [ + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'string', + 'useMessageForUser' => false, + 'message' => 'The type of the "string" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "string" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'int', + ], + 'path' => 'int', + 'useMessageForUser' => false, + 'message' => 'The type of the "int" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "int" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'float', + ], + 'path' => 'float', + 'useMessageForUser' => false, + 'message' => 'The type of the "float" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "float" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'bool', + ], + 'path' => 'bool', + 'useMessageForUser' => false, + 'message' => 'The type of the "bool" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "bool" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'dateTime', + 'useMessageForUser' => true, + 'message' => '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.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'dateTimeImmutable', + 'useMessageForUser' => true, + 'message' => '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.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'dateTimeZone', + 'useMessageForUser' => true, + 'message' => 'The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'splFileInfo', + 'useMessageForUser' => true, + 'message' => 'The provided "data:" URI is not valid.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'uuid', + 'useMessageForUser' => true, + 'message' => 'The data is not a valid UUID string representation.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'array', + ], + 'path' => 'array', + 'useMessageForUser' => false, + 'message' => 'The type of the "array" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "array" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => 'collection[1].string', + 'useMessageForUser' => false, + 'message' => 'The type of the "string" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\Php74Full" must be one of "string" ("null" given).', + ], + [ + 'currentType' => 'array', + 'expectedTypes' => [ + 'unknown', + ], + 'path' => 'php74FullWithConstructor', + 'useMessageForUser' => true, + 'message' => 'Failed to create object because the object miss the "constructorArgument" property.', + ], + ]; + + $this->assertSame($expected, $exceptionsAsArray); + } + + /** @requires PHP 7.4 */ + public function testCollectDenormalizationErrors2() + { + $json = ' + [ + { + "string": null + }, + { + "string": null + } + ]'; + + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + + $serializer = new Serializer( + [ + new ArrayDenormalizer(), + new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)), + ], + ['json' => new JsonEncoder()] + ); + + try { + $serializer->deserialize($json, Php74Full::class.'[]', 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + + $this->fail(); + } catch (\Throwable $th) { + $this->assertInstanceOf(PartialDenormalizationException::class, $th); + } + + $this->assertCount(2, $th->getData()); + $this->assertInstanceOf(Php74Full::class, $th->getData()[0]); + $this->assertInstanceOf(Php74Full::class, $th->getData()[1]); + + $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + return [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ]; + }, $th->getErrors()); + + $expected = [ + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => '[0].string', + 'useMessageForUser' => false, + 'message' => 'The type of the "string" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "string" ("null" given).', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', + ], + 'path' => '[1].string', + 'useMessageForUser' => false, + 'message' => 'The type of the "string" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "string" ("null" given).', + ], + ]; + + $this->assertSame($expected, $exceptionsAsArray); + } } class Model