From 364bd7f4fcb437b9a2d99362a37575f8b95fb4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Fri, 23 Jul 2021 12:56:50 +0200 Subject: [PATCH] [Serializer] Add support for denormalizing invalid datetime without throwing an exception --- UPGRADE-5.4.md | 5 +++ UPGRADE-6.0.md | 1 + .../Tests/Functional/app/config/framework.yml | 6 +++ src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Normalizer/DateTimeNormalizer.php | 27 ++++++++++++- .../Normalizer/DateTimeNormalizerTest.php | 40 +++++++++++++++++-- .../Features/ContextMetadataTestTrait.php | 4 +- .../Tests/Normalizer/ObjectNormalizerTest.php | 4 +- 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/UPGRADE-5.4.md b/UPGRADE-5.4.md index eca17fa0181e4..140179c31f9b1 100644 --- a/UPGRADE-5.4.md +++ b/UPGRADE-5.4.md @@ -45,3 +45,8 @@ Security * Deprecate `TokenInterface:isAuthenticated()` and `setAuthenticated()` methods without replacement. Security tokens won't have an "authenticated" flag anymore, so they will always be considered authenticated * Deprecate `DeauthenticatedEvent`, use `TokenDeauthenticatedEvent` instead + +Serializer +---------- + + * Deprecate not setting a value for the context key `DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY` diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 4667082741c33..89b2021a504f2 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -349,6 +349,7 @@ Serializer * Removed `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead. * `ArrayDenormalizer` does not implement `SerializerAwareInterface` anymore. * The annotation classes cannot be constructed by passing an array of parameters as first argument anymore, use named arguments instead + * The default context value for `DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY` becomes `false`. TwigBundle ---------- diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml index bfe7e24b338d7..b691afaa61345 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml @@ -13,3 +13,9 @@ framework: services: logger: { class: Psr\Log\NullLogger } + + serializer.normalizer.datetime: + class: Symfony\Component\Serializer\Normalizer\DateTimeNormalizer + arguments: + $defaultContext: + throw_exception_on_invalid_key: false diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 23480900a9242..059d8a4b747f3 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support of PHP backed enumerations + * Add support for denormalizing invalid datetime without throwing an exception 5.3 --- diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php index 19f9efdc0840a..bc46aa6806610 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php @@ -17,6 +17,7 @@ /** * Normalizes an object implementing the {@see \DateTimeInterface} to a date string. * Denormalizes a date string to an instance of {@see \DateTime} or {@see \DateTimeImmutable}. + * The denormalization may return the raw data if invalid according to the value of $context[self::THROW_EXCEPTION_ON_INVALID_KEY]. * * @author Kévin Dunglas */ @@ -24,10 +25,13 @@ class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface, { public const FORMAT_KEY = 'datetime_format'; public const TIMEZONE_KEY = 'datetime_timezone'; + public const THROW_EXCEPTION_ON_INVALID_KEY = 'throw_exception_on_invalid_key'; private $defaultContext = [ self::FORMAT_KEY => \DateTime::RFC3339, self::TIMEZONE_KEY => null, + // BC layer to be moved to "false" in 6.0 + self::THROW_EXCEPTION_ON_INVALID_KEY => null, ]; private const SUPPORTED_TYPES = [ @@ -39,6 +43,10 @@ class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface, public function __construct(array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + + if (null === $this->defaultContext[self::THROW_EXCEPTION_ON_INVALID_KEY]) { + trigger_deprecation('symfony/serializer', '5.4', 'The key context "%s" of "%s" must be defined. The value will be "false" in Symfony 6.0.', self::THROW_EXCEPTION_ON_INVALID_KEY, __CLASS__); + } } /** @@ -77,15 +85,22 @@ public function supportsNormalization($data, string $format = null) * {@inheritdoc} * * @throws NotNormalizableValueException - * - * @return \DateTimeInterface */ public function denormalize($data, string $type, string $format = null, array $context = []) { $dateTimeFormat = $context[self::FORMAT_KEY] ?? null; + $throwExceptionOnInvalid = $context[self::THROW_EXCEPTION_ON_INVALID_KEY] ?? $this->defaultContext[self::THROW_EXCEPTION_ON_INVALID_KEY]; + // BC layer to be removed in 6.0 + if (null === $throwExceptionOnInvalid) { + $throwExceptionOnInvalid = true; + } $timezone = $this->getTimezone($context); if (null === $data || (\is_string($data) && '' === trim($data))) { + if (!$throwExceptionOnInvalid) { + return $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.'); } @@ -98,12 +113,20 @@ public function denormalize($data, string $type, string $format = null, array $c $dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors(); + if (!$throwExceptionOnInvalid) { + return $data; + } + 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']))); } try { return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone); } catch (\Exception $e) { + if (!$throwExceptionOnInvalid) { + return $data; + } + throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e); } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php index dadaf01ae7a9d..4b037468a3475 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -21,6 +22,8 @@ */ class DateTimeNormalizerTest extends TestCase { + use ExpectDeprecationTrait; + /** * @var DateTimeNormalizer */ @@ -28,7 +31,9 @@ class DateTimeNormalizerTest extends TestCase protected function setUp(): void { - $this->normalizer = new DateTimeNormalizer(); + $this->normalizer = new DateTimeNormalizer([ + DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true, + ]); } public function testSupportsNormalization() @@ -51,13 +56,13 @@ public function testNormalizeUsingFormatPassedInContext() public function testNormalizeUsingFormatPassedInConstructor() { - $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'y']); + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'y', DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]); $this->assertEquals('16', $normalizer->normalize(new \DateTime('2016/01/01', new \DateTimeZone('UTC')))); } public function testNormalizeUsingTimeZonePassedInConstructor() { - $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan')]); + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan'), DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]); $this->assertSame('2016-12-01T00:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('Japan')))); $this->assertSame('2016-12-01T09:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('UTC')))); @@ -184,7 +189,7 @@ public function testDenormalizeUsingTimezonePassedInConstructor() { $timezone = new \DateTimeZone('Japan'); $expected = new \DateTime('2016/12/01 17:35:00', $timezone); - $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => $timezone]); + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => $timezone, DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]); $this->assertEquals($expected, $normalizer->denormalize('2016.12.01 17:35:00', \DateTime::class, null, [ DateTimeNormalizer::FORMAT_KEY => 'Y.m.d H:i:s', @@ -276,4 +281,31 @@ public function testDenormalizeFormatMismatchThrowsException() $this->expectException(UnexpectedValueException::class); $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTimeInterface::class, null, [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d|']); } + + public function provideDenormalizeInvalidDataDontThrowsExceptionTests() + { + yield ['invalid date']; + yield [null]; + yield ['']; + yield [' ']; + yield [' 2016.01.01 ', [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d|']]; + yield ['2016-01-01T00:00:00+00:00', [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d|']]; + } + + /** @dataProvider provideDenormalizeInvalidDataDontThrowsExceptionTests */ + public function testDenormalizeInvalidDataDontThrowsException($data, array $context = []) + { + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]); + $this->assertSame($data, $normalizer->denormalize($data, \DateTimeInterface::class, null, $context)); + } + + /** + * @group legacy + */ + public function testLegacyConstructor() + { + $this->expectDeprecation('Since symfony/serializer 5.4: The key context "throw_exception_on_invalid_key" of "Symfony\Component\Serializer\Normalizer\DateTimeNormalizer" must be defined. The value will be "false" in Symfony 6.0.'); + + new DateTimeNormalizer(); + } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php index 374cacaf79d02..fe7505306ea68 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -32,7 +32,7 @@ public function testContextMetadataNormalize() { $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); - new Serializer([new DateTimeNormalizer(), $normalizer]); + new Serializer([new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]); $dummy = new ContextMetadataDummy(); $dummy->date = new \DateTime('2011-07-28T08:44:00.123+00:00'); @@ -52,7 +52,7 @@ public function testContextMetadataContextDenormalize() { $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); - new Serializer([new DateTimeNormalizer(), $normalizer]); + new Serializer([new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]); /** @var ContextMetadataDummy $dummy */ $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index 860c16f6036a4..f2dcc9bec880c 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -619,7 +619,7 @@ public function testDenomalizeRecursive() { $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); $normalizer = new ObjectNormalizer(null, null, null, $extractor); - $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]); + $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]); $obj = $serializer->denormalize([ 'inner' => ['foo' => 'foo', 'bar' => 'bar'], @@ -638,7 +638,7 @@ public function testAcceptJsonNumber() { $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); $normalizer = new ObjectNormalizer(null, null, null, $extractor); - $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]); + $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]); $this->assertSame(10.0, $serializer->denormalize(['number' => 10], JsonNumber::class, 'json')->number); $this->assertSame(10.0, $serializer->denormalize(['number' => 10], JsonNumber::class, 'jsonld')->number);