From 98acd62d25c5c8217ab269088c90537f7bfa4b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy=20=28T-bond=29?= Date: Thu, 24 Mar 2022 15:51:32 +0100 Subject: [PATCH] [Serializer] Fix denormalizing union types Fixes symfony#45818 --- .../Normalizer/AbstractObjectNormalizer.php | 52 +++++++++++------- .../Serializer/Tests/SerializerTest.php | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 5a6d3b78e61d9..e29a7cd18e6b6 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -404,6 +404,7 @@ private function validateAndDenormalize(string $currentClass, string $attribute, } $expectedTypes = []; + $isUnionType = \count($types) > 1; foreach ($types as $type) { if (null === $data && $type->isNullable()) { return null; @@ -455,29 +456,40 @@ private function validateAndDenormalize(string $currentClass, string $attribute, $expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true; - if (Type::BUILTIN_TYPE_OBJECT === $builtinType) { - if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class)); + // This try-catch should cover all NotNormalizableValueException (and all return branches after the first + // exception) so we could try denormalizing all types of an union type. If the target type is not an union + // type, we will just re-throw the catched exception. + // In the case of no denormalization succeeds with an union type, it will fall back to the default exception + // with the acceptable types list. + try { + if (Type::BUILTIN_TYPE_OBJECT === $builtinType) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class)); + } + + $childContext = $this->createChildContext($context, $attribute, $format); + if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) { + return $this->serializer->denormalize($data, $class, $format, $childContext); + } } - $childContext = $this->createChildContext($context, $attribute, $format); - if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) { - return $this->serializer->denormalize($data, $class, $format, $childContext); + // JSON only has a Number type corresponding to both int and float PHP types. + // PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert + // floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible). + // PHP's json_decode automatically converts Numbers without a decimal part to integers. + // To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when + // a float is expected. + if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) { + return (float) $data; } - } - - // JSON only has a Number type corresponding to both int and float PHP types. - // PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert - // floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible). - // PHP's json_decode automatically converts Numbers without a decimal part to integers. - // To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when - // a float is expected. - if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) { - return (float) $data; - } - if (('is_'.$builtinType)($data)) { - return $data; + if (('is_'.$builtinType)($data)) { + return $data; + } + } catch (NotNormalizableValueException $e) { + if (!$isUnionType) { + throw $e; + } } } @@ -639,7 +651,7 @@ private function getCacheKey(?string $format, array $context) 'ignored' => $this->ignoredAttributes, 'camelized' => $this->camelizedAttributes, ])); - } catch (\Exception $exception) { + } catch (\Exception $e) { // The context cannot be serialized, skip the cache return false; } diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index e5a8acbf03543..a95811a46f8d9 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; 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; @@ -33,6 +34,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; @@ -538,6 +540,38 @@ public function testNormalizePreserveEmptyArrayObject() $this->assertEquals('{"foo":{},"bar":["notempty"],"baz":{"nested":{}}}', $serializer->serialize($object, 'json', [AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true])); } + public function testUnionTypeDeserializable() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $serializer = new Serializer( + [ + new DateTimeNormalizer(), + new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)), + ], + ['json' => new JsonEncoder()] + ); + + $actual = $serializer->deserialize('{ "changed": null }', DummyUnionType::class, 'json', [ + DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + ]); + + $this->assertEquals((new DummyUnionType())->setChanged(null), $actual, 'Union type denormalization first case failed.'); + + $actual = $serializer->deserialize('{ "changed": "2022-03-22T16:15:05+0000" }', DummyUnionType::class, 'json', [ + DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + ]); + + $expectedDateTime = \DateTime::createFromFormat(\DateTime::ISO8601, '2022-03-22T16:15:05+0000'); + $this->assertEquals((new DummyUnionType())->setChanged($expectedDateTime), $actual, 'Union type denormalization second case failed.'); + + $actual = $serializer->deserialize('{ "changed": false }', DummyUnionType::class, 'json', [ + DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + ]); + + $this->assertEquals(new DummyUnionType(), $actual, 'Union type denormalization third case failed.'); + } + private function serializerWithClassDiscriminator() { $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); @@ -610,6 +644,26 @@ public function __construct($value) } } +class DummyUnionType +{ + /** + * @var \DateTime|bool|null + */ + public $changed = false; + + /** + * @param \DateTime|bool|null + * + * @return $this + */ + public function setChanged($changed): self + { + $this->changed = $changed; + + return $this; + } +} + interface NormalizerAwareNormalizer extends NormalizerInterface, NormalizerAwareInterface { }