Skip to content

[Serializer] Add support for collecting type error during denormalization #42502

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <lyrixx@lyrixx.info>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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:
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
*/
interface DenormalizerInterface
{
public const COLLECT_DENORMALIZATION_ERRORS = 'collect_denormalization_errors';

/**
* Denormalizes data back into an object of the given class.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading