Skip to content

Commit 114fca3

Browse files
committed
feature #17660 [Serializer] Integrate the PropertyInfo Component (recursive denormalization and hardening) (mihai-stancu, dunglas)
This PR was merged into the 3.1-dev branch. Discussion ---------- [Serializer] Integrate the PropertyInfo Component (recursive denormalization and hardening) | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #16143, #17193, #14844 | License | MIT | Doc PR | todo Integrates the PropertyInfo Component in order to: * denormalize a graph of objects recursively (see tests) * harden the hydratation logic The hardening part is interesting. Considering the following example: ```php class Foo { public function setDate(\DateTimeInterface $date) { } } // initialize $normalizer $normalizer->denormalize(['date' => 1234], Foo::class); ``` Previously, a PHP error was thrown because the type passed to the setter (an int) doesn't match the one checked with the typehint. With the PropertyInfo integration, an `UnexpectedValueExcption` is throw instead. It's especially interesting for web APIs dealing with JSON documents. For instance in API Platform, previously a 500 error was thrown, but thanks to this fix a 400 HTTP code with a descriptive error message will be returned. (/cc @csarrazi @mRoca @blazarecki, it's an alternative to https://github.com/dunglas/php-to-json-schema for protecting an API). /cc @mihai-stancu Commits ------- 5194482 [Serializer] Integrate the PropertyInfo Component 6b464b0 Recursive denormalize using PropertyInfo
2 parents 51d075a + 5194482 commit 114fca3

8 files changed

+171
-12
lines changed

src/Symfony/Component/Serializer/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CHANGELOG
1616
* added support for serializing objects that implement `DateTimeInterface`
1717
* added `AbstractObjectNormalizer` as a base class for normalizers that deal
1818
with objects
19+
* added support to relation deserialization
1920

2021
2.7.0
2122
-----

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

+70-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
use Symfony\Component\Serializer\Exception\CircularReferenceException;
1616
use Symfony\Component\Serializer\Exception\LogicException;
1717
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
18+
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
19+
use Symfony\Component\PropertyInfo\Type;
20+
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
21+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
1822

1923
/**
2024
* Base class for a normalizer dealing with objects.
@@ -26,8 +30,16 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
2630
const ENABLE_MAX_DEPTH = 'enable_max_depth';
2731
const DEPTH_KEY_PATTERN = 'depth_%s::%s';
2832

33+
private $propertyTypeExtractor;
2934
private $attributesCache = array();
3035

36+
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null)
37+
{
38+
parent::__construct($classMetadataFactory, $nameConverter);
39+
40+
$this->propertyTypeExtractor = $propertyTypeExtractor;
41+
}
42+
3143
/**
3244
* {@inheritdoc}
3345
*/
@@ -76,7 +88,7 @@ public function normalize($object, $format = null, array $context = array())
7688

7789
foreach ($stack as $attribute => $attributeValue) {
7890
if (!$this->serializer instanceof NormalizerInterface) {
79-
throw new LogicException(sprintf('Cannot normalize attribute "%s" because injected serializer is not a normalizer', $attribute));
91+
throw new LogicException(sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer', $attribute));
8092
}
8193

8294
$data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $context));
@@ -173,12 +185,15 @@ public function denormalize($data, $class, $format = null, array $context = arra
173185
$allowed = $allowedAttributes === false || in_array($attribute, $allowedAttributes);
174186
$ignored = in_array($attribute, $this->ignoredAttributes);
175187

176-
if ($allowed && !$ignored) {
177-
try {
178-
$this->setAttributeValue($object, $attribute, $value, $format, $context);
179-
} catch (InvalidArgumentException $e) {
180-
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
181-
}
188+
if (!$allowed || $ignored) {
189+
continue;
190+
}
191+
192+
$value = $this->validateAndDenormalize($class, $attribute, $value, $format, $context);
193+
try {
194+
$this->setAttributeValue($object, $attribute, $value, $format, $context);
195+
} catch (InvalidArgumentException $e) {
196+
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
182197
}
183198
}
184199

@@ -210,6 +225,54 @@ protected function isAttributeToNormalize($object, $attributeName, &$context)
210225
return !in_array($attributeName, $this->ignoredAttributes) && !$this->isMaxDepthReached(get_class($object), $attributeName, $context);
211226
}
212227

228+
/**
229+
* Validates the submitted data and denormalizes it.
230+
*
231+
* @param string $currentClass
232+
* @param string $attribute
233+
* @param mixed $data
234+
* @param string|null $format
235+
* @param array $context
236+
*
237+
* @return mixed
238+
*
239+
* @throws UnexpectedValueException
240+
* @throws LogicException
241+
*/
242+
private function validateAndDenormalize($currentClass, $attribute, $data, $format, array $context)
243+
{
244+
if (null === $this->propertyTypeExtractor || null === $types = $this->propertyTypeExtractor->getTypes($currentClass, $attribute)){
245+
return $data;
246+
}
247+
248+
$expectedTypes = array();
249+
foreach ($types as $type) {
250+
if (null === $data && $type->isNullable()) {
251+
return;
252+
}
253+
254+
$builtinType = $type->getBuiltinType();
255+
$class = $type->getClassName();
256+
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
257+
258+
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
259+
if (!$this->serializer instanceof DenormalizerInterface) {
260+
throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer', $attribute, $class));
261+
}
262+
263+
if ($this->serializer->supportsDenormalization($data, $class, $format)) {
264+
return $this->serializer->denormalize($data, $class, $format, $context);
265+
}
266+
}
267+
268+
if (call_user_func('is_'.$builtinType, $data)) {
269+
return $data;
270+
}
271+
}
272+
273+
throw new UnexpectedValueException(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), gettype($data)));
274+
}
275+
213276
/**
214277
* Sets an attribute and apply the name converter if necessary.
215278
*

src/Symfony/Component/Serializer/Normalizer/GetSetMethodNormalizer.php

+34
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,40 @@ class GetSetMethodNormalizer extends AbstractObjectNormalizer
3636
{
3737
private static $setterAccessibleCache = array();
3838

39+
/**
40+
* {@inheritdoc}
41+
*
42+
* @throws RuntimeException
43+
*/
44+
public function denormalize($data, $class, $format = null, array $context = array())
45+
{
46+
$allowedAttributes = $this->getAllowedAttributes($class, $context, true);
47+
$normalizedData = $this->prepareForDenormalization($data);
48+
49+
$reflectionClass = new \ReflectionClass($class);
50+
$object = $this->instantiateObject($normalizedData, $class, $context, $reflectionClass, $allowedAttributes);
51+
52+
$classMethods = get_class_methods($object);
53+
foreach ($normalizedData as $attribute => $value) {
54+
if ($this->nameConverter) {
55+
$attribute = $this->nameConverter->denormalize($attribute);
56+
}
57+
58+
$allowed = $allowedAttributes === false || in_array($attribute, $allowedAttributes);
59+
$ignored = in_array($attribute, $this->ignoredAttributes);
60+
61+
if ($allowed && !$ignored) {
62+
$setter = 'set'.ucfirst($attribute);
63+
64+
if (in_array($setter, $classMethods) && !$reflectionClass->getMethod($setter)->isStatic()) {
65+
$object->$setter($value);
66+
}
67+
}
68+
}
69+
70+
return $object;
71+
}
72+
3973
/**
4074
* {@inheritdoc}
4175
*/

src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
1515
use Symfony\Component\PropertyAccess\PropertyAccess;
1616
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
17+
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
1718
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
1819
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
1920

@@ -29,9 +30,9 @@ class ObjectNormalizer extends AbstractObjectNormalizer
2930
*/
3031
protected $propertyAccessor;
3132

32-
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null)
33+
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null)
3334
{
34-
parent::__construct($classMetadataFactory, $nameConverter);
35+
parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor);
3536

3637
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
3738
}

src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ public function provideCallbacks()
390390

391391
/**
392392
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
393-
* @expectedExceptionMessage Cannot normalize attribute "object" because injected serializer is not a normalizer
393+
* @expectedExceptionMessage Cannot normalize attribute "object" because the injected serializer is not a normalizer
394394
*/
395395
public function testUnableToNormalizeObjectAttribute()
396396
{

src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php

+59-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
namespace Symfony\Component\Serializer\Tests\Normalizer;
1313

1414
use Doctrine\Common\Annotations\AnnotationReader;
15+
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
16+
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
1517
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
18+
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
1619
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
1720
use Symfony\Component\Serializer\Serializer;
1821
use Symfony\Component\Serializer\SerializerInterface;
@@ -372,7 +375,7 @@ public function provideCallbacks()
372375

373376
/**
374377
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
375-
* @expectedExceptionMessage Cannot normalize attribute "object" because injected serializer is not a normalizer
378+
* @expectedExceptionMessage Cannot normalize attribute "object" because the injected serializer is not a normalizer
376379
*/
377380
public function testUnableToNormalizeObjectAttribute()
378381
{
@@ -506,6 +509,29 @@ public function testThrowUnexpectedValueException()
506509
{
507510
$this->normalizer->denormalize(array('foo' => 'bar'), ObjectTypeHinted::class);
508511
}
512+
513+
public function testDenomalizeRecursive()
514+
{
515+
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
516+
$serializer = new Serializer(array(new DateTimeNormalizer(), $normalizer));
517+
518+
$obj = $serializer->denormalize(array('inner' => array('foo' => 'foo', 'bar' => 'bar'), 'date' => '1988/01/21'), ObjectOuter::class);
519+
$this->assertEquals('foo', $obj->getInner()->foo);
520+
$this->assertEquals('bar', $obj->getInner()->bar);
521+
$this->assertEquals('1988-01-21', $obj->getDate()->format('Y-m-d'));
522+
}
523+
524+
/**
525+
* @expectedException UnexpectedValueException
526+
* @expectedExceptionMessage The type of the "date" attribute for class "Symfony\Component\Serializer\Tests\Normalizer\ObjectOuter" must be one of "DateTimeInterface" ("string" given).
527+
*/
528+
public function testRejectInvalidType()
529+
{
530+
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
531+
$serializer = new Serializer(array($normalizer));
532+
533+
$serializer->denormalize(array('date' => 'foo'), ObjectOuter::class);
534+
}
509535
}
510536

511537
class ObjectDummy
@@ -673,3 +699,35 @@ public function setFoo(array $f)
673699
{
674700
}
675701
}
702+
703+
class ObjectOuter
704+
{
705+
private $inner;
706+
private $date;
707+
708+
public function getInner()
709+
{
710+
return $this->inner;
711+
}
712+
713+
public function setInner(ObjectInner $inner)
714+
{
715+
$this->inner = $inner;
716+
}
717+
718+
public function setDate(\DateTimeInterface $date)
719+
{
720+
$this->date = $date;
721+
}
722+
723+
public function getDate()
724+
{
725+
return $this->date;
726+
}
727+
}
728+
729+
class ObjectInner
730+
{
731+
public $foo;
732+
public $bar;
733+
}

src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ public function testDenormalizeShouldIgnoreStaticProperty()
349349

350350
/**
351351
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
352-
* @expectedExceptionMessage Cannot normalize attribute "bar" because injected serializer is not a normalizer
352+
* @expectedExceptionMessage Cannot normalize attribute "bar" because the injected serializer is not a normalizer
353353
*/
354354
public function testUnableToNormalizeObjectAttribute()
355355
{

src/Symfony/Component/Serializer/composer.json

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"symfony/property-access": "~2.8|~3.0",
2525
"symfony/http-foundation": "~2.8|~3.0",
2626
"symfony/cache": "~3.1",
27+
"symfony/property-info": "~2.8|~3.0",
2728
"doctrine/annotations": "~1.0",
2829
"doctrine/cache": "~1.0"
2930
},
@@ -32,6 +33,7 @@
3233
},
3334
"suggest": {
3435
"psr/cache-implementation": "For using the metadata cache.",
36+
"symfony/property-info": "To deserialize relations.",
3537
"symfony/yaml": "For using the default YAML mapping loader.",
3638
"symfony/config": "For using the XML mapping loader.",
3739
"symfony/property-access": "For using the ObjectNormalizer.",

0 commit comments

Comments
 (0)