diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index ea6dec7fd6bd1..937fdc5dcb43c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -67,6 +67,7 @@ use Symfony\Component\Serializer\Encoder\YamlEncoder; use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory; use Symfony\Component\Serializer\Normalizer\DataUriNormalizer; +use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; @@ -1522,6 +1523,13 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $definition->addTag('serializer.normalizer', array('priority' => -920)); } + if (class_exists(DateIntervalNormalizer::class)) { + // Run before serializer.normalizer.object + $definition = $container->register('serializer.normalizer.dateinterval', DateIntervalNormalizer::class); + $definition->setPublic(false); + $definition->addTag('serializer.normalizer', array('priority' => -915)); + } + if (class_exists('Symfony\Component\Serializer\Normalizer\DateTimeNormalizer')) { // Run before serializer.normalizer.object $definition = $container->register('serializer.normalizer.datetime', DateTimeNormalizer::class); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 991eda1ea6ac6..67be87baa8a3b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -34,6 +34,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; @@ -776,6 +777,21 @@ public function testDataUriNormalizerRegistered() $this->assertEquals(-920, $tag[0]['priority']); } + public function testDateIntervalNormalizerRegistered() + { + if (!class_exists(DateIntervalNormalizer::class)) { + $this->markTestSkipped('The DateIntervalNormalizer has been introduced in the Serializer Component version 3.4.'); + } + + $container = $this->createContainerFromFile('full'); + + $definition = $container->getDefinition('serializer.normalizer.dateinterval'); + $tag = $definition->getTag('serializer.normalizer'); + + $this->assertEquals(DateIntervalNormalizer::class, $definition->getClass()); + $this->assertEquals(-915, $tag[0]['priority']); + } + public function testDateTimeNormalizerRegistered() { if (!class_exists('Symfony\Component\Serializer\Normalizer\DateTimeNormalizer')) { diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 2e10bd469bab4..ef59f22499264 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * added `AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT` context option to disable throwing an `UnexpectedValueException` on a type mismatch + * added support for serializing `DateInterval` objects 3.3.0 ----- diff --git a/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php new file mode 100644 index 0000000000000..3628cd670b499 --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Normalizer; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Normalizes an instance of {@see \DateInterval} to an interval string. + * Denormalizes an interval string to an instance of {@see \DateInterval}. + * + * @author Jérôme Parmentier + */ +class DateIntervalNormalizer implements NormalizerInterface, DenormalizerInterface +{ + const FORMAT_KEY = 'dateinterval_format'; + + /** + * @var string + */ + private $format; + + /** + * @param string $format + */ + public function __construct($format = 'P%yY%mM%dDT%hH%iM%sS') + { + $this->format = $format; + } + + /** + * {@inheritdoc} + * + * @throws InvalidArgumentException + */ + public function normalize($object, $format = null, array $context = array()) + { + if (!$object instanceof \DateInterval) { + throw new InvalidArgumentException('The object must be an instance of "\DateInterval".'); + } + + $dateIntervalFormat = isset($context[self::FORMAT_KEY]) ? $context[self::FORMAT_KEY] : $this->format; + + return $object->format($dateIntervalFormat); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return $data instanceof \DateInterval; + } + + /** + * {@inheritdoc} + * + * @throws InvalidArgumentException + * @throws UnexpectedValueException + */ + public function denormalize($data, $class, $format = null, array $context = array()) + { + if (!is_string($data)) { + throw new InvalidArgumentException(sprintf('Data expected to be a string, %s given.', gettype($data))); + } + + if (!$this->isISO8601($data)) { + throw new UnexpectedValueException('Expected a valid ISO 8601 interval string.'); + } + + $dateIntervalFormat = isset($context[self::FORMAT_KEY]) ? $context[self::FORMAT_KEY] : $this->format; + + $valuePattern = '/^'.preg_replace('/%([yYmMdDhHiIsSwW])(\w)/', '(?P<$1>\d+)$2', $dateIntervalFormat).'$/'; + if (!preg_match($valuePattern, $data)) { + throw new UnexpectedValueException(sprintf('Value "%s" contains intervals not accepted by format "%s".', $data, $dateIntervalFormat)); + } + + try { + return new \DateInterval($data); + } catch (\Exception $e) { + throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return \DateInterval::class === $type; + } + + private function isISO8601($string) + { + return preg_match('/^P(?=\w*(?:\d|%\w))(?:\d+Y|%[yY]Y)?(?:\d+M|%[mM]M)?(?:(?:\d+D|%[dD]D)|(?:\d+W|%[wW]W))?(?:T(?:\d+H|[hH]H)?(?:\d+M|[iI]M)?(?:\d+S|[sS]S)?)?$/', $string); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/DateIntervalNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/DateIntervalNormalizerTest.php new file mode 100644 index 0000000000000..f6dc6c2475e53 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/DateIntervalNormalizerTest.php @@ -0,0 +1,137 @@ + + */ +class DateIntervalNormalizerTest extends TestCase +{ + /** + * @var DateIntervalNormalizer + */ + private $normalizer; + + protected function setUp() + { + $this->normalizer = new DateIntervalNormalizer(); + } + + public function dataProviderISO() + { + $data = array( + array('P%YY%MM%DDT%HH%IM%SS', 'P00Y00M00DT00H00M00S', 'PT0S'), + array('P%yY%mM%dDT%hH%iM%sS', 'P0Y0M0DT0H0M0S', 'PT0S'), + array('P%yY%mM%dDT%hH%iM%sS', 'P10Y2M3DT16H5M6S', 'P10Y2M3DT16H5M6S'), + array('P%yY%mM%dDT%hH%iM', 'P10Y2M3DT16H5M', 'P10Y2M3DT16H5M'), + array('P%yY%mM%dDT%hH', 'P10Y2M3DT16H', 'P10Y2M3DT16H'), + array('P%yY%mM%dD', 'P10Y2M3D', 'P10Y2M3DT0H'), + ); + + return $data; + } + + public function testSupportsNormalization() + { + $this->assertTrue($this->normalizer->supportsNormalization(new \DateInterval('P00Y00M00DT00H00M00S'))); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalize() + { + $this->assertEquals('P0Y0M0DT0H0M0S', $this->normalizer->normalize(new \DateInterval('PT0S'))); + } + + /** + * @dataProvider dataProviderISO + */ + public function testNormalizeUsingFormatPassedInContext($format, $output, $input) + { + $this->assertEquals($output, $this->normalizer->normalize(new \DateInterval($input), null, array(DateIntervalNormalizer::FORMAT_KEY => $format))); + } + + /** + * @dataProvider dataProviderISO + */ + public function testNormalizeUsingFormatPassedInConstructor($format, $output, $input) + { + $this->assertEquals($output, (new DateIntervalNormalizer($format))->normalize(new \DateInterval($input))); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\InvalidArgumentException + * @expectedExceptionMessage The object must be an instance of "\DateInterval". + */ + public function testNormalizeInvalidObjectThrowsException() + { + $this->normalizer->normalize(new \stdClass()); + } + + public function testSupportsDenormalization() + { + $this->assertTrue($this->normalizer->supportsDenormalization('P00Y00M00DT00H00M00S', \DateInterval::class)); + $this->assertFalse($this->normalizer->supportsDenormalization('foo', 'Bar')); + } + + public function testDenormalize() + { + $this->assertDateIntervalEquals(new \DateInterval('P00Y00M00DT00H00M00S'), $this->normalizer->denormalize('P00Y00M00DT00H00M00S', \DateInterval::class)); + } + + /** + * @dataProvider dataProviderISO + */ + public function testDenormalizeUsingFormatPassedInContext($format, $input, $output) + { + $this->assertDateIntervalEquals(new \DateInterval($output), $this->normalizer->denormalize($input, \DateInterval::class, null, array(DateIntervalNormalizer::FORMAT_KEY => $format))); + } + + /** + * @dataProvider dataProviderISO + */ + public function testDenormalizeUsingFormatPassedInConstructor($format, $input, $output) + { + $this->assertDateIntervalEquals(new \DateInterval($output), (new DateIntervalNormalizer($format))->denormalize($input, \DateInterval::class)); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\InvalidArgumentException + */ + public function testDenormalizeExpectsString() + { + $this->normalizer->denormalize(1234, \DateInterval::class); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\UnexpectedValueException + * @expectedExceptionMessage Expected a valid ISO 8601 interval string. + */ + public function testDenormalizeNonISO8601IntervalStringThrowsException() + { + $this->normalizer->denormalize('10 years 2 months 3 days', \DateInterval::class, null); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\UnexpectedValueException + */ + public function testDenormalizeInvalidDataThrowsException() + { + $this->normalizer->denormalize('invalid interval', \DateInterval::class); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\UnexpectedValueException + */ + public function testDenormalizeFormatMismatchThrowsException() + { + $this->normalizer->denormalize('P00Y00M00DT00H00M00S', \DateInterval::class, null, array(DateIntervalNormalizer::FORMAT_KEY => 'P%yY%mM%dD')); + } + + private function assertDateIntervalEquals(\DateInterval $expected, \DateInterval $actual) + { + $this->assertEquals($expected->format('%RP%yY%mM%dDT%hH%iM%sS'), $actual->format('%RP%yY%mM%dDT%hH%iM%sS')); + } +}