diff --git a/src/Symfony/Component/Serializer/Exception/AggregableExceptionInterface.php b/src/Symfony/Component/Serializer/Exception/AggregableExceptionInterface.php new file mode 100644 index 0000000000000..b7aa4beb6cf37 --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/AggregableExceptionInterface.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +interface AggregableExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Serializer/Exception/AggregatedException.php b/src/Symfony/Component/Serializer/Exception/AggregatedException.php new file mode 100644 index 0000000000000..ea8df59f36139 --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/AggregatedException.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +final class AggregatedException extends RuntimeException implements AggregableExceptionInterface +{ + /** + * This property is public for performance reasons to reduce the number of hot path function calls. + * + * @var bool + * + * @internal + */ + public $isEmpty = true; + + /** + * @var array + */ + private $collection; + + public function __construct() + { + parent::__construct('Errors occurred during the denormalization process'); + } + + public function put(string $param, AggregableExceptionInterface $exception): self + { + $this->isEmpty = false; + $this->collection[$param] = $exception; + + return $this; + } + + /** + * @return array + */ + public function all(): array + { + $result = []; + + foreach ($this->collection as $param => $exception) { + if ($exception instanceof self) { + foreach ($exception->all() as $nestedParams => $nestedException) { + $result["$param.$nestedParams"] = $nestedException; + } + + continue; + } + + $result[$param] = $exception; + } + + return $result; + } +} diff --git a/src/Symfony/Component/Serializer/Exception/InvalidArgumentException.php b/src/Symfony/Component/Serializer/Exception/InvalidArgumentException.php index f4e645c4487ff..6391f2b871da9 100644 --- a/src/Symfony/Component/Serializer/Exception/InvalidArgumentException.php +++ b/src/Symfony/Component/Serializer/Exception/InvalidArgumentException.php @@ -16,6 +16,6 @@ * * @author Johannes M. Schmitt */ -class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface, AggregableExceptionInterface { } diff --git a/src/Symfony/Component/Serializer/Exception/MissingConstructorArgumentsException.php b/src/Symfony/Component/Serializer/Exception/MissingConstructorArgumentsException.php index c433cbff64fba..1e6bcdebca1c2 100644 --- a/src/Symfony/Component/Serializer/Exception/MissingConstructorArgumentsException.php +++ b/src/Symfony/Component/Serializer/Exception/MissingConstructorArgumentsException.php @@ -14,6 +14,6 @@ /** * @author Maxime VEBER */ -class MissingConstructorArgumentsException extends RuntimeException +class MissingConstructorArgumentsException extends RuntimeException implements AggregableExceptionInterface { } diff --git a/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php b/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php index 58adf72cab147..c5ae338da76ed 100644 --- a/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php +++ b/src/Symfony/Component/Serializer/Exception/NotNormalizableValueException.php @@ -14,6 +14,6 @@ /** * @author Christian Flothmann */ -class NotNormalizableValueException extends UnexpectedValueException +class NotNormalizableValueException extends UnexpectedValueException implements AggregableExceptionInterface { } diff --git a/src/Symfony/Component/Serializer/Exception/VariadicConstructorArgumentsException.php b/src/Symfony/Component/Serializer/Exception/VariadicConstructorArgumentsException.php new file mode 100644 index 0000000000000..a0ec7712bc3e0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/VariadicConstructorArgumentsException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +final class VariadicConstructorArgumentsException extends RuntimeException implements AggregableExceptionInterface +{ +} diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 4a03ab851a3a2..aa3568e1b5c39 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -11,11 +11,14 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\Serializer\Exception\AggregableExceptionInterface; +use Symfony\Component\Serializer\Exception\AggregatedException; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\VariadicConstructorArgumentsException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -351,6 +354,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex $constructorParameters = $constructor->getParameters(); + $aggregatedException = new AggregatedException(); $params = []; foreach ($constructorParameters as $constructorParameter) { $paramName = $constructorParameter->name; @@ -361,12 +365,28 @@ protected function instantiateObject(array &$data, string $class, array &$contex if ($constructorParameter->isVariadic()) { if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) { if (!\is_array($data[$paramName])) { - throw new RuntimeException(sprintf('Cannot create an instance of "%s" from serialized data because the variadic parameter "%s" can only accept an array.', $class, $constructorParameter->name)); + $e = new VariadicConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because the variadic parameter "%s" can only accept an array.', $class, $constructorParameter->name)); + if (!($context[self::COLLECT_EXCEPTIONS] ?? false)) { + throw $e; + } + + $aggregatedException->put($paramName, $e); + unset($data[$key]); + continue; } $variadicParameters = []; - foreach ($data[$paramName] as $parameterData) { - $variadicParameters[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + foreach ($data[$paramName] as $paramKey => $parameterData) { + try { + $variadicParameters[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + } catch (AggregableExceptionInterface $e) { + if (!($context[self::COLLECT_EXCEPTIONS] ?? false)) { + throw $e; + } + + $aggregatedException->put("$paramName.$paramKey", $e); + continue; + } } $params = array_merge($params, $variadicParameters); @@ -382,7 +402,15 @@ protected function instantiateObject(array &$data, string $class, array &$contex } // Don't run set for a parameter passed to the constructor - $params[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + try { + $params[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + } catch (AggregableExceptionInterface $e) { + if (!($context[self::COLLECT_EXCEPTIONS] ?? false)) { + throw $e; + } + + $aggregatedException->put($paramName, $e); + } unset($data[$key]); } elseif (\array_key_exists($key, $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class] ?? [])) { $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key]; @@ -391,10 +419,20 @@ protected function instantiateObject(array &$data, string $class, array &$contex } elseif ($constructorParameter->isDefaultValueAvailable()) { $params[] = $constructorParameter->getDefaultValue(); } 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)); + $e = 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 (!($context[self::COLLECT_EXCEPTIONS] ?? false)) { + throw $e; + } + + $aggregatedException->put($paramName, $e); } } + if (($context[self::COLLECT_EXCEPTIONS] ?? false) && !$aggregatedException->isEmpty) { + throw $aggregatedException; + } + if ($constructor->isConstructor()) { return $reflectionClass->newInstanceArgs($params); } else { diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index aa1be48cfbaf5..a41ef51d2a8f7 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -18,6 +18,8 @@ use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; +use Symfony\Component\Serializer\Exception\AggregableExceptionInterface; +use Symfony\Component\Serializer\Exception\AggregatedException; use Symfony\Component\Serializer\Exception\ExtraAttributesException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -311,6 +313,8 @@ public function denormalize($data, string $type, string $format = null, array $c $object = $this->instantiateObject($normalizedData, $type, $context, $reflectionClass, $allowedAttributes, $format); $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); + $aggregatedException = new AggregatedException(); + foreach ($normalizedData as $attribute => $value) { if ($this->nameConverter) { $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context); @@ -331,11 +335,20 @@ public function denormalize($data, string $type, string $format = null, array $c } } - $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context); try { - $this->setAttributeValue($object, $attribute, $value, $format, $context); - } catch (InvalidArgumentException $e) { - throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e); + $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context); + try { + $this->setAttributeValue($object, $attribute, $value, $format, $context); + } catch (InvalidArgumentException $e) { + throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e); + } + } catch (AggregableExceptionInterface $e) { + if (!($context[self::COLLECT_EXCEPTIONS] ?? false)) { + throw $e; + } + + $aggregatedException->put($attribute, $e); + continue; } } @@ -343,6 +356,10 @@ public function denormalize($data, string $type, string $format = null, array $c throw new ExtraAttributesException($extraAttributes); } + if (($context[self::COLLECT_EXCEPTIONS] ?? false) && !$aggregatedException->isEmpty) { + throw $aggregatedException; + } + return $object; } diff --git a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php index d903b3912d019..855273a529f9f 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php +++ b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php @@ -24,6 +24,11 @@ */ interface DenormalizerInterface { + /** + * Collects denormalization exceptions instead of throwing out the first one. + */ + public const COLLECT_EXCEPTIONS = 'collect_exceptions'; + /** * Denormalizes data back into an object of the given class. * diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsArrayDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsArrayDummy.php new file mode 100644 index 0000000000000..96d17fe38a761 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsArrayDummy.php @@ -0,0 +1,11 @@ +foo = $foo; + $this->bar = $bar; + $this->baz = $baz; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsVariadicObjectDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsVariadicObjectDummy.php new file mode 100644 index 0000000000000..8ff807a5bbc0d --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/CollectErrorsVariadicObjectDummy.php @@ -0,0 +1,17 @@ +foo = $foo; + $this->args = $args; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 3f27877840143..ca55c38c739cf 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -16,7 +16,10 @@ 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\JsonEncoder; +use Symfony\Component\Serializer\Exception\AggregableExceptionInterface; +use Symfony\Component\Serializer\Exception\AggregatedException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -30,6 +33,7 @@ use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer; @@ -42,6 +46,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsDummy; use Symfony\Component\Serializer\Tests\Fixtures\DummyFirstChildQuux; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageInterface; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne; @@ -640,6 +645,52 @@ public function testDeserializeAndUnwrap() $serializer->deserialize($jsonData, __NAMESPACE__.'\Model', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]']) ); } + + public function testCollectDenormalizationErrors() + { + $jsonData = '{"int":"string","array":"string","arrayOfObjects":[{"date":"string"}],"stringArrayOfObjects":"string","object":{"foo":"1","bar":"1"},"variadicObject":{"foo":1,"args":[{"foo":"1"},{"foo":1},{"foo":"1","args":1}]}}'; + + $expected = [ + 'int' => 'The type of the "int" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsDummy" must be one of "int" ("string" given).', + 'array' => 'The type of the "array" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsDummy" must be one of "array" ("string" given).', + 'arrayOfObjects.date' => 'DateTimeImmutable::__construct(): Failed to parse time string (string) at position 0 (s): The timezone could not be found in the database', + 'stringArrayOfObjects' => 'Data expected to be an array, string given.', + 'object.foo' => 'The type of the "foo" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsObjectDummy" must be one of "int" ("string" given).', + 'object.bar' => 'The type of the "bar" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsObjectDummy" must be one of "int" ("string" given).', + 'object.baz' => 'Cannot create an instance of "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsObjectDummy" from serialized data because its constructor requires parameter "baz" to be present.', + 'variadicObject.args.0.foo' => 'The type of the "foo" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsVariadicObjectDummy" must be one of "int" ("string" given).', + 'variadicObject.args.2.foo' => 'The type of the "foo" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsVariadicObjectDummy" must be one of "int" ("string" given).', + 'variadicObject.args.2.args' => 'Cannot create an instance of "Symfony\Component\Serializer\Tests\Fixtures\CollectErrorsVariadicObjectDummy" from serialized data because the variadic parameter "args" can only accept an array.', + ]; + + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $serializer = new Serializer( + [ + new DateTimeNormalizer(), + new ObjectNormalizer(null, null, null, $extractor), + new ArrayDenormalizer(), + ], + [ + 'json' => new JsonEncoder(), + ] + ); + + try { + $serializer->deserialize($jsonData, CollectErrorsDummy::class, 'json', [ + Serializer::COLLECT_EXCEPTIONS => true, + ]); + } catch (AggregatedException $exception) { + $this->assertEquals( + $expected, + array_map( + static function (AggregableExceptionInterface $exception) { + return $exception->getMessage(); + }, + $exception->all() + ) + ); + } + } } class Model