diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 8457b5eef6c3a..7a43e63c9b7a4 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -79,15 +79,15 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn public const DEFAULT_CONSTRUCTOR_ARGUMENTS = 'default_constructor_arguments'; /** - * Hashmap of field name => callable to normalize this field. + * Hashmap of field name => callable to (de)normalize this field. * * The callable is called if the field is encountered with the arguments: * - * - mixed $attributeValue value of this field - * - object $object the whole object being normalized - * - string $attributeName name of the attribute being normalized - * - string $format the requested format - * - array $context the serialization context + * - mixed $attributeValue value of this field + * - object|string $object the whole object being normalized or the object's class being denormalized + * - string $attributeName name of the attribute being (de)normalized + * - string $format the requested format + * - array $context the serialization context */ public const CALLBACKS = 'callbacks'; @@ -168,17 +168,7 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory $this->nameConverter = $nameConverter; $this->defaultContext = array_merge($this->defaultContext, $defaultContext); - if (isset($this->defaultContext[self::CALLBACKS])) { - if (!\is_array($this->defaultContext[self::CALLBACKS])) { - throw new InvalidArgumentException(sprintf('The "%s" default context option must be an array of callables.', self::CALLBACKS)); - } - - foreach ($this->defaultContext[self::CALLBACKS] as $attribute => $callback) { - if (!\is_callable($callback)) { - throw new InvalidArgumentException(sprintf('Invalid callback found for attribute "%s" in the "%s" default context option.', $attribute, self::CALLBACKS)); - } - } - } + $this->validateCallbackContext($this->defaultContext, 'default'); if (isset($this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER]) && !\is_callable($this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER])) { throw new InvalidArgumentException(sprintf('Invalid callback found in the "%s" default context option.', self::CIRCULAR_REFERENCE_HANDLER)); @@ -220,11 +210,11 @@ public function setCircularReferenceHandler(callable $circularReferenceHandler) } /** - * Sets normalization callbacks. + * Sets (de)normalization callbacks. * * @deprecated since Symfony 4.2 * - * @param callable[] $callbacks Help normalize the result + * @param callable[] $callbacks Help (de)normalize the result * * @return self * @@ -532,7 +522,7 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara throw new LogicException(sprintf('Cannot create an instance of "%s" from serialized data because the serializer inject in "%s" is not a denormalizer.', $parameterClass, static::class)); } - return $this->serializer->denormalize($parameterData, $parameterClass, $format, $this->createChildContext($context, $parameterName, $format)); + $parameterData = $this->serializer->denormalize($parameterData, $parameterClass, $format, $this->createChildContext($context, $parameterName, $format)); } } catch (\ReflectionException $e) { throw new RuntimeException(sprintf('Could not determine the class of the parameter "%s".', $parameterName), 0, $e); @@ -544,7 +534,7 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara return null; } - return $parameterData; + return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); } /** @@ -565,4 +555,46 @@ protected function createChildContext(array $parentContext, $attribute/*, ?strin return $parentContext; } + + /** + * Validate callbacks set in context. + * + * @param string $contextType Used to specify which context is invalid in exceptions + * + * @throws InvalidArgumentException + */ + final protected function validateCallbackContext(array $context, string $contextType = ''): void + { + if (!isset($context[self::CALLBACKS])) { + return; + } + + if (!\is_array($context[self::CALLBACKS])) { + throw new InvalidArgumentException(sprintf('The "%s"%s context option must be an array of callables.', self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); + } + + foreach ($context[self::CALLBACKS] as $attribute => $callback) { + if (!\is_callable($callback)) { + throw new InvalidArgumentException(sprintf('Invalid callback found for attribute "%s" in the "%s"%s context option.', $attribute, self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); + } + } + } + + /** + * Apply callbacks set in context. + * + * @param mixed $value + * @param object|string $object Can be either the object being normalizing or the object's class being denormalized + * + * @return mixed + */ + final protected function applyCallbacks($value, $object, string $attribute, ?string $format, array $context) + { + /** + * @var callable|null + */ + $callback = $context[self::CALLBACKS][$attribute] ?? $this->defaultContext[self::CALLBACKS][$attribute] ?? $this->callbacks[$attribute] ?? null; + + return $callback ? $callback($value, $object, $attribute, $format, $context) : $value; + } } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 57df0b6b8e521..388ba10373594 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -145,17 +145,7 @@ public function normalize($object, $format = null, array $context = []) $context['cache_key'] = $this->getCacheKey($format, $context); } - if (isset($context[self::CALLBACKS])) { - if (!\is_array($context[self::CALLBACKS])) { - throw new InvalidArgumentException(sprintf('The "%s" context option must be an array of callables.', self::CALLBACKS)); - } - - foreach ($context[self::CALLBACKS] as $attribute => $callback) { - if (!\is_callable($callback)) { - throw new InvalidArgumentException(sprintf('Invalid callback found for attribute "%s" in the "%s" context option.', $attribute, self::CALLBACKS)); - } - } - } + $this->validateCallbackContext($context); if ($this->isCircularReference($object, $context)) { return $this->handleCircularReference($object, $format, $context); @@ -203,13 +193,7 @@ public function normalize($object, $format = null, array $context = []) $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $context); } - /** - * @var callable|null - */ - $callback = $context[self::CALLBACKS][$attribute] ?? $this->defaultContext[self::CALLBACKS][$attribute] ?? $this->callbacks[$attribute] ?? null; - if ($callback) { - $attributeValue = $callback($attributeValue, $object, $attribute, $format, $context); - } + $attributeValue = $this->applyCallbacks($attributeValue, $object, $attribute, $format, $context); if (null !== $attributeValue && !is_scalar($attributeValue)) { $stack[$attribute] = $attributeValue; @@ -346,6 +330,8 @@ public function denormalize($data, $type, $format = null, array $context = []) $context['cache_key'] = $this->getCacheKey($format, $context); } + $this->validateCallbackContext($context); + $allowedAttributes = $this->getAllowedAttributes($type, $context, true); $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; @@ -375,6 +361,8 @@ public function denormalize($data, $type, $format = null, array $context = []) } $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context); + $value = $this->applyCallbacks($value, $resolvedClass, $attribute, $format, $context); + try { $this->setAttributeValue($object, $attribute, $value, $format, $context); } catch (InvalidArgumentException $e) { @@ -509,7 +497,9 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara return parent::denormalizeParameter($class, $parameter, $parameterName, $parameterData, $context, $format); } - return $this->validateAndDenormalize($class->getName(), $parameterName, $parameterData, $format, $context); + $parameterData = $this->validateAndDenormalize($class->getName(), $parameterName, $parameterData, $format, $context); + + return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); } /** diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksObject.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksObject.php index d2290e6dda0e5..4ed3ff1c4f0a4 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksObject.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksObject.php @@ -6,13 +6,34 @@ class CallbacksObject { public $bar; - public function __construct($bar = null) + /** + * @var string|null + */ + public $foo; + + public function __construct($bar = null, string $foo = null) { $this->bar = $bar; + $this->foo = $foo; } public function getBar() { return $this->bar; } + + public function setBar($bar) + { + $this->bar = $bar; + } + + public function getFoo(): ?string + { + return $this->foo; + } + + public function setFoo(?string $foo) + { + $this->foo = $foo; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksTestTrait.php index 459b01c92408f..4a14693002bd9 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksTestTrait.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CallbacksTestTrait.php @@ -2,6 +2,9 @@ namespace Symfony\Component\Serializer\Tests\Normalizer\Features; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -12,20 +15,92 @@ trait CallbacksTestTrait { abstract protected function getNormalizerForCallbacks(): NormalizerInterface; + abstract protected function getNormalizerForCallbacksWithPropertyTypeExtractor(): NormalizerInterface; + /** - * @dataProvider provideCallbacks + * @dataProvider provideNormalizeCallbacks */ - public function testCallbacks($callbacks, $valueBar, $result) + public function testNormalizeCallbacks($callbacks, $valueBar, $result) { $normalizer = $this->getNormalizerForCallbacks(); $obj = new CallbacksObject(); $obj->bar = $valueBar; - $this->assertEquals( - $result, - $normalizer->normalize($obj, 'any', ['callbacks' => $callbacks]) - ); + $this->assertSame($result, $normalizer->normalize($obj, 'any', ['callbacks' => $callbacks])); + } + + /** + * @dataProvider provideNormalizeCallbacks + */ + public function testNormalizeCallbacksWithTypedProperty($callbacks, $valueBar, $result) + { + $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); + + $obj = new CallbacksObject(); + $obj->bar = $valueBar; + + $this->assertSame($result, $normalizer->normalize($obj, 'any', ['callbacks' => $callbacks])); + } + + /** + * @dataProvider provideNormalizeCallbacks + */ + public function testNormalizeCallbacksWithNoConstructorArgument($callbacks, $valueBar, $result) + { + $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); + + $obj = new class() extends CallbacksObject { + public function __construct() + { + } + }; + + $obj->bar = $valueBar; + + $this->assertSame($result, $normalizer->normalize($obj, 'any', ['callbacks' => $callbacks])); + } + + /** + * @dataProvider provideDenormalizeCallbacks + */ + public function testDenormalizeCallbacks($callbacks, $valueBar, $result) + { + $normalizer = $this->getNormalizerForCallbacks(); + + $obj = $normalizer->denormalize(['bar' => $valueBar], CallbacksObject::class, 'any', ['callbacks' => $callbacks]); + $this->assertInstanceof(CallbacksObject::class, $obj); + $this->assertEquals($result, $obj); + } + + /** + * @dataProvider providerDenormalizeCallbacksWithTypedProperty + */ + public function testDenormalizeCallbacksWithTypedProperty($callbacks, $valueBar, $result) + { + $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); + + $obj = $normalizer->denormalize(['foo' => $valueBar], CallbacksObject::class, 'any', ['callbacks' => $callbacks]); + $this->assertInstanceof(CallbacksObject::class, $obj); + $this->assertEquals($result, $obj); + } + + /** + * @dataProvider providerDenormalizeCallbacksWithTypedProperty + */ + public function testDenormalizeCallbacksWithNoConstructorArgument($callbacks, $valueBar, $result) + { + $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); + + $objWithNoConstructorArgument = new class() extends CallbacksObject { + public function __construct() + { + } + }; + + $obj = $normalizer->denormalize(['foo' => $valueBar], \get_class($objWithNoConstructorArgument), 'any', ['callbacks' => $callbacks]); + $this->assertInstanceof(\get_class($objWithNoConstructorArgument), $obj); + $this->assertEquals($result->getBar(), $obj->getBar()); } /** @@ -42,7 +117,7 @@ public function testUncallableCallbacks($callbacks) $normalizer->normalize($obj, null, ['callbacks' => $callbacks]); } - public function provideCallbacks() + public function provideNormalizeCallbacks() { return [ 'Change a string' => [ @@ -54,7 +129,7 @@ public function provideCallbacks() }, ], 'baz', - ['bar' => 'baz'], + ['bar' => 'baz', 'foo' => null], ], 'Null an item' => [ [ @@ -67,7 +142,7 @@ public function provideCallbacks() }, ], 'baz', - ['bar' => null], + ['bar' => null, 'foo' => null], ], 'Format a date' => [ [ @@ -78,7 +153,71 @@ public function provideCallbacks() }, ], new \DateTime('2011-09-10 06:30:00'), - ['bar' => '10-09-2011 06:30:00'], + ['bar' => '10-09-2011 06:30:00', 'foo' => null], + ], + 'Collect a property' => [ + [ + 'bar' => function (array $bars) { + $result = ''; + foreach ($bars as $bar) { + $result .= $bar->bar; + } + + return $result; + }, + ], + [new CallbacksObject('baz'), new CallbacksObject('quux')], + ['bar' => 'bazquux', 'foo' => null], + ], + 'Count a property' => [ + [ + 'bar' => function (array $bars) { + return \count($bars); + }, + ], + [new CallbacksObject(), new CallbacksObject()], + ['bar' => 2, 'foo' => null], + ], + ]; + } + + public function provideDenormalizeCallbacks(): array + { + return [ + 'Change a string' => [ + [ + 'bar' => function ($bar) { + $this->assertEquals('bar', $bar); + + return $bar; + }, + ], + 'bar', + new CallbacksObject('bar'), + ], + 'Null an item' => [ + [ + 'bar' => function ($value, $object, $attributeName, $format, $context) { + $this->assertSame('baz', $value); + $this->assertTrue(is_a($object, CallbacksObject::class, true)); + $this->assertSame('bar', $attributeName); + $this->assertSame('any', $format); + $this->assertIsArray($context); + }, + ], + 'baz', + new CallbacksObject(null), + ], + 'Format a date' => [ + [ + 'bar' => function ($bar) { + $this->assertIsString($bar); + + return \DateTime::createFromFormat('d-m-Y H:i:s', $bar); + }, + ], + '10-09-2011 06:30:00', + new CallbacksObject(new \DateTime('2011-09-10 06:30:00')), ], 'Collect a property' => [ [ @@ -92,7 +231,7 @@ public function provideCallbacks() }, ], [new CallbacksObject('baz'), new CallbacksObject('quux')], - ['bar' => 'bazquux'], + new CallbacksObject('bazquux'), ], 'Count a property' => [ [ @@ -101,7 +240,37 @@ public function provideCallbacks() }, ], [new CallbacksObject(), new CallbacksObject()], - ['bar' => 2], + new CallbacksObject(2), + ], + ]; + } + + public function providerDenormalizeCallbacksWithTypedProperty(): array + { + return [ + 'Change a typed string' => [ + [ + 'foo' => function ($foo) { + $this->assertEquals('foo', $foo); + + return $foo; + }, + ], + 'foo', + new CallbacksObject(null, 'foo'), + ], + 'Null an typed item' => [ + [ + 'foo' => function ($value, $object, $attributeName, $format, $context) { + $this->assertSame('fool', $value); + $this->assertTrue(is_a($object, CallbacksObject::class, true)); + $this->assertSame('foo', $attributeName); + $this->assertSame('any', $format); + $this->assertIsArray($context); + }, + ], + 'fool', + new CallbacksObject(null, null), ], ]; } @@ -113,4 +282,18 @@ public function provideInvalidCallbacks() [['bar' => 'thisisnotavalidfunction']], ]; } + + protected function getCallbackPropertyTypeExtractor(): PropertyInfoExtractor + { + $reflectionExtractor = new ReflectionExtractor(); + $phpDocExtractor = new PhpDocExtractor(); + + return new PropertyInfoExtractor( + [$reflectionExtractor, $phpDocExtractor], + [$reflectionExtractor, $phpDocExtractor], + [$reflectionExtractor, $phpDocExtractor], + [$reflectionExtractor, $phpDocExtractor], + [$reflectionExtractor, $phpDocExtractor] + ); + } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php index 0a542f4ea53f3..117a157f0024c 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -227,6 +227,13 @@ public function testConstructorWArgWithPrivateMutator() $this->assertEquals('bar', $obj->getFoo()); } + protected function getNormalizerForCallbacksWithPropertyTypeExtractor(): GetSetMethodNormalizer + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + + return new GetSetMethodNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), $this->getCallbackPropertyTypeExtractor()); + } + protected function getNormalizerForCallbacks(): GetSetMethodNormalizer { $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); @@ -235,7 +242,7 @@ protected function getNormalizerForCallbacks(): GetSetMethodNormalizer } /** - * @dataProvider provideCallbacks + * @dataProvider provideNormalizeCallbacks */ public function testLegacyCallbacks($callbacks, $value, $result) { diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index 50ed2ad0f0c43..c6c02bfb8568e 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -399,7 +399,7 @@ protected function getNormalizerForCallbacks(): ObjectNormalizer } /** - * @dataProvider provideCallbacks + * @dataProvider provideNormalizeCallbacks */ public function testLegacyCallbacks($callbacks, $value, $result) { @@ -422,6 +422,11 @@ public function testLegacyUncallableCallbacks($callbacks) $this->normalizer->setCallbacks($callbacks); } + protected function getNormalizerForCallbacksWithPropertyTypeExtractor(): ObjectNormalizer + { + return new ObjectNormalizer(null, null, null, $this->getCallbackPropertyTypeExtractor()); + } + // circular reference protected function getNormalizerForCircularReference(array $defaultContext): ObjectNormalizer diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php index be8b17124b592..c2c907d225faa 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php @@ -188,7 +188,7 @@ protected function getNormalizerForCallbacks(): PropertyNormalizer } /** - * @dataProvider provideCallbacks + * @dataProvider provideNormalizeCallbacks */ public function testLegacyCallbacks($callbacks, $value, $result) { @@ -212,6 +212,11 @@ public function testLegacyUncallableCallbacks($callbacks) $this->normalizer->setCallbacks($callbacks); } + protected function getNormalizerForCallbacksWithPropertyTypeExtractor(): PropertyNormalizer + { + return new PropertyNormalizer(null, null, $this->getCallbackPropertyTypeExtractor()); + } + protected function getNormalizerForCircularReference(array $defaultContext): PropertyNormalizer { $normalizer = new PropertyNormalizer(null, null, null, null, null, $defaultContext);