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
This commit is contained in:
Kévin Dunglas 2016-04-19 17:18:49 +02:00
commit 114fca3126
8 changed files with 171 additions and 12 deletions

View File

@ -16,6 +16,7 @@ CHANGELOG
* added support for serializing objects that implement `DateTimeInterface`
* added `AbstractObjectNormalizer` as a base class for normalizers that deal
with objects
* added support to relation deserialization
2.7.0
-----

View File

@ -15,6 +15,10 @@ use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\CircularReferenceException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* Base class for a normalizer dealing with objects.
@ -26,8 +30,16 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
const ENABLE_MAX_DEPTH = 'enable_max_depth';
const DEPTH_KEY_PATTERN = 'depth_%s::%s';
private $propertyTypeExtractor;
private $attributesCache = array();
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null)
{
parent::__construct($classMetadataFactory, $nameConverter);
$this->propertyTypeExtractor = $propertyTypeExtractor;
}
/**
* {@inheritdoc}
*/
@ -76,7 +88,7 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
foreach ($stack as $attribute => $attributeValue) {
if (!$this->serializer instanceof NormalizerInterface) {
throw new LogicException(sprintf('Cannot normalize attribute "%s" because injected serializer is not a normalizer', $attribute));
throw new LogicException(sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer', $attribute));
}
$data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $context));
@ -173,12 +185,15 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
$allowed = $allowedAttributes === false || in_array($attribute, $allowedAttributes);
$ignored = in_array($attribute, $this->ignoredAttributes);
if ($allowed && !$ignored) {
try {
$this->setAttributeValue($object, $attribute, $value, $format, $context);
} catch (InvalidArgumentException $e) {
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
}
if (!$allowed || $ignored) {
continue;
}
$value = $this->validateAndDenormalize($class, $attribute, $value, $format, $context);
try {
$this->setAttributeValue($object, $attribute, $value, $format, $context);
} catch (InvalidArgumentException $e) {
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
}
}
@ -210,6 +225,54 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
return !in_array($attributeName, $this->ignoredAttributes) && !$this->isMaxDepthReached(get_class($object), $attributeName, $context);
}
/**
* Validates the submitted data and denormalizes it.
*
* @param string $currentClass
* @param string $attribute
* @param mixed $data
* @param string|null $format
* @param array $context
*
* @return mixed
*
* @throws UnexpectedValueException
* @throws LogicException
*/
private function validateAndDenormalize($currentClass, $attribute, $data, $format, array $context)
{
if (null === $this->propertyTypeExtractor || null === $types = $this->propertyTypeExtractor->getTypes($currentClass, $attribute)){
return $data;
}
$expectedTypes = array();
foreach ($types as $type) {
if (null === $data && $type->isNullable()) {
return;
}
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
$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));
}
if ($this->serializer->supportsDenormalization($data, $class, $format)) {
return $this->serializer->denormalize($data, $class, $format, $context);
}
}
if (call_user_func('is_'.$builtinType, $data)) {
return $data;
}
}
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)));
}
/**
* Sets an attribute and apply the name converter if necessary.
*

View File

@ -36,6 +36,40 @@ class GetSetMethodNormalizer extends AbstractObjectNormalizer
{
private static $setterAccessibleCache = array();
/**
* {@inheritdoc}
*
* @throws RuntimeException
*/
public function denormalize($data, $class, $format = null, array $context = array())
{
$allowedAttributes = $this->getAllowedAttributes($class, $context, true);
$normalizedData = $this->prepareForDenormalization($data);
$reflectionClass = new \ReflectionClass($class);
$object = $this->instantiateObject($normalizedData, $class, $context, $reflectionClass, $allowedAttributes);
$classMethods = get_class_methods($object);
foreach ($normalizedData as $attribute => $value) {
if ($this->nameConverter) {
$attribute = $this->nameConverter->denormalize($attribute);
}
$allowed = $allowedAttributes === false || in_array($attribute, $allowedAttributes);
$ignored = in_array($attribute, $this->ignoredAttributes);
if ($allowed && !$ignored) {
$setter = 'set'.ucfirst($attribute);
if (in_array($setter, $classMethods) && !$reflectionClass->getMethod($setter)->isStatic()) {
$object->$setter($value);
}
}
}
return $object;
}
/**
* {@inheritdoc}
*/

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Serializer\Normalizer;
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
@ -29,9 +30,9 @@ class ObjectNormalizer extends AbstractObjectNormalizer
*/
protected $propertyAccessor;
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null)
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null)
{
parent::__construct($classMetadataFactory, $nameConverter);
parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor);
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
}

View File

@ -390,7 +390,7 @@ class GetSetMethodNormalizerTest extends \PHPUnit_Framework_TestCase
/**
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
* @expectedExceptionMessage Cannot normalize attribute "object" because injected serializer is not a normalizer
* @expectedExceptionMessage Cannot normalize attribute "object" because the injected serializer is not a normalizer
*/
public function testUnableToNormalizeObjectAttribute()
{

View File

@ -12,7 +12,10 @@
namespace Symfony\Component\Serializer\Tests\Normalizer;
use Doctrine\Common\Annotations\AnnotationReader;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
@ -372,7 +375,7 @@ class ObjectNormalizerTest extends \PHPUnit_Framework_TestCase
/**
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
* @expectedExceptionMessage Cannot normalize attribute "object" because injected serializer is not a normalizer
* @expectedExceptionMessage Cannot normalize attribute "object" because the injected serializer is not a normalizer
*/
public function testUnableToNormalizeObjectAttribute()
{
@ -506,6 +509,29 @@ class ObjectNormalizerTest extends \PHPUnit_Framework_TestCase
{
$this->normalizer->denormalize(array('foo' => 'bar'), ObjectTypeHinted::class);
}
public function testDenomalizeRecursive()
{
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
$serializer = new Serializer(array(new DateTimeNormalizer(), $normalizer));
$obj = $serializer->denormalize(array('inner' => array('foo' => 'foo', 'bar' => 'bar'), 'date' => '1988/01/21'), ObjectOuter::class);
$this->assertEquals('foo', $obj->getInner()->foo);
$this->assertEquals('bar', $obj->getInner()->bar);
$this->assertEquals('1988-01-21', $obj->getDate()->format('Y-m-d'));
}
/**
* @expectedException UnexpectedValueException
* @expectedExceptionMessage The type of the "date" attribute for class "Symfony\Component\Serializer\Tests\Normalizer\ObjectOuter" must be one of "DateTimeInterface" ("string" given).
*/
public function testRejectInvalidType()
{
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
$serializer = new Serializer(array($normalizer));
$serializer->denormalize(array('date' => 'foo'), ObjectOuter::class);
}
}
class ObjectDummy
@ -673,3 +699,35 @@ class ObjectTypeHinted
{
}
}
class ObjectOuter
{
private $inner;
private $date;
public function getInner()
{
return $this->inner;
}
public function setInner(ObjectInner $inner)
{
$this->inner = $inner;
}
public function setDate(\DateTimeInterface $date)
{
$this->date = $date;
}
public function getDate()
{
return $this->date;
}
}
class ObjectInner
{
public $foo;
public $bar;
}

View File

@ -349,7 +349,7 @@ class PropertyNormalizerTest extends \PHPUnit_Framework_TestCase
/**
* @expectedException \Symfony\Component\Serializer\Exception\LogicException
* @expectedExceptionMessage Cannot normalize attribute "bar" because injected serializer is not a normalizer
* @expectedExceptionMessage Cannot normalize attribute "bar" because the injected serializer is not a normalizer
*/
public function testUnableToNormalizeObjectAttribute()
{

View File

@ -24,6 +24,7 @@
"symfony/property-access": "~2.8|~3.0",
"symfony/http-foundation": "~2.8|~3.0",
"symfony/cache": "~3.1",
"symfony/property-info": "~2.8|~3.0",
"doctrine/annotations": "~1.0",
"doctrine/cache": "~1.0"
},
@ -32,6 +33,7 @@
},
"suggest": {
"psr/cache-implementation": "For using the metadata cache.",
"symfony/property-info": "To deserialize relations.",
"symfony/yaml": "For using the default YAML mapping loader.",
"symfony/config": "For using the XML mapping loader.",
"symfony/property-access": "For using the ObjectNormalizer.",