From 3fdd9256c6a621ec3b7b474c42de1e3f6e8ef6bf Mon Sep 17 00:00:00 2001 From: Camille Dejoye Date: Sun, 22 Nov 2020 18:02:52 +0100 Subject: [PATCH] add BuiltinTypeDenormalizer handle builtin types and not just scalars Looking towards the possible refactoring of the AbstractObjectNormalizer it will be more convenient to have a denormalizer capable of handling builtin types instead of just scalar ones. Except for `null`, `iterable`, `array` and `object` types: - `null` could be handled here with little work but I'm not sure it's a good idea - `iterable` does not provide enough information to validte the items so it might be better to not handle it so that the user gave a "better" type - `array` and `object`, it's simplier to not support them so that we don't have to deal with a complex handling of priority within the normalizers --- UPGRADE-5.3.md | 5 + UPGRADE-6.0.md | 5 + src/Symfony/Component/Serializer/CHANGELOG.md | 5 + .../Normalizer/BuiltinTypeDenormalizer.php | 117 +++++++++++++++++ .../Component/Serializer/Serializer.php | 3 + .../BuiltinTypeDenormalizerTest.php | 123 ++++++++++++++++++ .../Serializer/Tests/SerializerTest.php | 83 ++++++++++-- .../Component/Serializer/composer.json | 1 + 8 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Component/Serializer/Normalizer/BuiltinTypeDenormalizer.php create mode 100644 src/Symfony/Component/Serializer/Tests/Normalizer/BuiltinTypeDenormalizerTest.php diff --git a/UPGRADE-5.3.md b/UPGRADE-5.3.md index 4d991a30805c2..40368a0c183a9 100644 --- a/UPGRADE-5.3.md +++ b/UPGRADE-5.3.md @@ -22,3 +22,8 @@ Security -------- * Deprecated voters that do not return a valid decision when calling the `vote` method. + +Serializer +---------- + + * Deprecated denormalizing scalar values without registering the `BuiltinTypeDenormalizer` diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 2d9090a3c58c2..dd5734e56698e 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -226,6 +226,11 @@ Validator ->addDefaultDoctrineAnnotationReader(); ``` +Serializer +---------- + + * Removed the denormalization of scalar values without normalizer, add the `BuiltinTypeDenormalizer` to the `Serializer` + Yaml ---- diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 97ae3fd62fdab..eb83907f0fede 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3.0 +----- + + * [DEPRECATION] denormalizing scalar values without registering the `BuiltinTypeDenormalizer` + 5.2.0 ----- diff --git a/src/Symfony/Component/Serializer/Normalizer/BuiltinTypeDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/BuiltinTypeDenormalizer.php new file mode 100644 index 0000000000000..93a886b682f89 --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/BuiltinTypeDenormalizer.php @@ -0,0 +1,117 @@ + + * + * 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\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Encoder\XmlEncoder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +final class BuiltinTypeDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface +{ + private const TYPE_INT = 'int'; + private const TYPE_FLOAT = 'float'; + private const TYPE_STRING = 'string'; + private const TYPE_BOOL = 'bool'; + private const TYPE_RESOURCE = 'resource'; + private const TYPE_CALLABLE = 'callable'; + + private const SUPPORTED_TYPES = [ + self::TYPE_INT => true, + self::TYPE_BOOL => true, + self::TYPE_FLOAT => true, + self::TYPE_STRING => true, + self::TYPE_RESOURCE => true, + self::TYPE_CALLABLE => true, + ]; + + /** + * {@inheritdoc} + */ + public function denormalize($data, string $type, string $format = null, array $context = []) + { + $dataType = get_debug_type($data); + + if (!(isset(self::SUPPORTED_TYPES[$dataType]) || 0 === strpos($dataType, self::TYPE_RESOURCE) || \is_callable($data))) { + throw new InvalidArgumentException(sprintf('Data expected to be of one of the types in "%s" ("%s" given).', implode(', ', array_keys(self::SUPPORTED_TYPES)), get_debug_type($data))); + } + + // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, + // if a value is meant to be a string, float, int or a boolean value from the serialized representation. + // That's why we have to transform the values, if one of these non-string basic datatypes is expected. + if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { + switch ($type) { + case self::TYPE_BOOL: + // according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" + if ('false' === $data || '0' === $data) { + return false; + } + if ('true' === $data || '1' === $data) { + return true; + } + + throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data)); + case self::TYPE_INT: + if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) { + return (int) $data; + } + + throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data)); + case self::TYPE_FLOAT: + if (is_numeric($data)) { + return (float) $data; + } + + switch ($data) { + case 'NaN': + return \NAN; + case 'INF': + return \INF; + case '-INF': + return -\INF; + default: + throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, $data)); + } + } + } + + // JSON only has a Number type corresponding to both int and float PHP types. + // PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert + // floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible). + // PHP's json_decode automatically converts Numbers without a decimal part to integers. + // To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when + // a float is expected. + if (self::TYPE_FLOAT === $type && \is_int($data) && false !== strpos($format, JsonEncoder::FORMAT)) { + return (float) $data; + } + + if (!('is_'.$type)($data)) { + throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data))); + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, string $type, string $format = null) + { + return isset(self::SUPPORTED_TYPES[$type]); + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Serializer/Serializer.php b/src/Symfony/Component/Serializer/Serializer.php index 6414caf900472..8ffde2c59a14f 100644 --- a/src/Symfony/Component/Serializer/Serializer.php +++ b/src/Symfony/Component/Serializer/Serializer.php @@ -22,6 +22,7 @@ use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\BuiltinTypeDenormalizer; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; @@ -193,6 +194,8 @@ public function denormalize($data, string $type, string $format = null, array $c // Check for a denormalizer first, e.g. the data is wrapped if (!$normalizer && isset(self::SCALAR_TYPES[$type])) { + trigger_deprecation('symfony/serializer', '5.2', 'Denormalizing scalar values without registering the "%s" is deprecated.', BuiltinTypeDenormalizer::class); + if (!('is_'.$type)($data)) { throw new NotNormalizableValueException(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data))); } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/BuiltinTypeDenormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/BuiltinTypeDenormalizerTest.php new file mode 100644 index 0000000000000..aa7e34438ebcc --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/BuiltinTypeDenormalizerTest.php @@ -0,0 +1,123 @@ +denormalizer = new BuiltinTypeDenormalizer(); + } + + /** + * @dataProvider provideSupportedTypes + */ + public function testSupportsDenormalization(string $supportedType): void + { + $this->assertTrue($this->denormalizer->supportsDenormalization(null, $supportedType)); + } + + public function provideSupportedTypes(): iterable + { + return [['int'], ['float'], ['string'], ['bool'], ['resource'], ['callable']]; + } + + /** + * @dataProvider provideUnsupportedTypes + */ + public function testUnsupportsDenormalization(string $unsupportedType): void + { + $this->assertFalse($this->denormalizer->supportsDenormalization(null, $unsupportedType)); + } + + public function provideUnsupportedTypes(): iterable + { + return [['null'], ['array'], ['iterable'], ['object'], ['int[]']]; + } + + /** + * @dataProvider provideInvalidData + */ + public function testDenormalizeInvalidDataThrowsException($invalidData): void + { + $this->expectException(InvalidArgumentException::class); + $this->denormalizer->denormalize($invalidData, 'int'); + } + + public function provideInvalidData(): iterable + { + return [ + 'array' => [[1, 2]], + 'object' => [new \stdClass()], + 'null' => [null], + ]; + } + + /** + * @dataProvider provideNotNormalizableData + */ + public function testDenormalizeNotNormalizableDataThrowsException($data, string $type, string $format): void + { + $this->expectException(NotNormalizableValueException::class); + $this->denormalizer->denormalize($data, $type, $format); + } + + public function provideNotNormalizableData(): iterable + { + return [ + 'not a string' => [true, 'string', 'json'], + 'not an integer' => [3.1, 'int', 'json'], + 'not an integer (xml/csv)' => ['+12', 'int', 'xml'], + 'not a float' => [false, 'float', 'json'], + 'not a float (xml/csv)' => ['nan', 'float', 'xml'], + 'not a boolean (json)' => [0, 'bool', 'json'], + 'not a boolean (xml/csv)' => ['test', 'bool', 'xml'], + ]; + } + + /** + * @dataProvider provideNormalizableData + */ + public function testDenormalize($expectedResult, $data, string $type, string $format = null): void + { + $result = $this->denormalizer->denormalize($data, $type, $format); + + if (\is_float($expectedResult) && is_nan($expectedResult)) { + $this->assertNan($result); + } else { + $this->assertSame($expectedResult, $result); + } + } + + public function provideNormalizableData(): iterable + { + return [ + 'string' => ['1', '1', 'string', 'json'], + 'integer' => [-3, -3, 'int', 'json'], + 'integer (xml/csv)' => [-12, '-12', 'int', 'xml'], + 'float' => [3.14, 3.14, 'float', 'json'], + 'float without decimals' => [3.0, 3, 'float', 'json'], + 'NaN (xml/csv)' => [\NAN, 'NaN', 'float', 'xml'], + 'INF (xml/csv)' => [\INF, 'INF', 'float', 'xml'], + '-INF (xml/csv)' => [-\INF, '-INF', 'float', 'xml'], + 'boolean: true (json)' => [true, true, 'bool', 'json'], + 'boolean: false (json)' => [false, false, 'bool', 'json'], + "boolean: 'true' (xml/csv)" => [true, 'true', 'bool', 'xml'], + "boolean: '1' (xml/csv)" => [true, '1', 'bool', 'xml'], + "boolean: 'false' (xml/csv)" => [false, 'false', 'bool', 'xml'], + "boolean: '0' (xml/csv)" => [false, '0', 'bool', 'xml'], + 'callable' => [[$this, 'provideInvalidData'], [$this, 'provideInvalidData'], 'callable', null], + 'resource' => [$r = fopen(__FILE__, 'r'), $r, 'resource', null], + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 3f27877840143..ef47637289f5f 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -29,6 +29,7 @@ use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BuiltinTypeDenormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -556,7 +557,7 @@ public function testNormalizeScalarArray() $this->assertSame('[" spaces ","@Ca$e%"]', $serializer->serialize([' spaces ', '@Ca$e%'], 'json')); } - public function testDeserializeScalar() + public function testLegacyDeserializeScalar() { $serializer = new Serializer([], ['json' => new JsonEncoder()]); @@ -568,35 +569,35 @@ public function testDeserializeScalar() $this->assertSame('@Ca$e%', $serializer->deserialize('"@Ca$e%"', 'string', 'json')); } - public function testDeserializeLegacyScalarType() + public function testLegacyDeserializeLegacyScalarType() { $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $serializer->deserialize('42', 'integer', 'json'); } - public function testDeserializeScalarTypeToCustomType() + public function testLegacyDeserializeScalarTypeToCustomType() { $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $serializer->deserialize('"something"', Foo::class, 'json'); } - public function testDeserializeNonscalarTypeToScalar() + public function testLegacyDeserializeNonscalarTypeToScalar() { $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $serializer->deserialize('{"foo":true}', 'string', 'json'); } - public function testDeserializeInconsistentScalarType() + public function testLegacyDeserializeInconsistentScalarType() { $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $serializer->deserialize('"42"', 'int', 'json'); } - public function testDeserializeScalarArray() + public function testLegacyDeserializeScalarArray() { $serializer = new Serializer([new ArrayDenormalizer()], ['json' => new JsonEncoder()]); @@ -606,20 +607,86 @@ public function testDeserializeScalarArray() $this->assertSame([' spaces ', '@Ca$e%'], $serializer->deserialize('[" spaces ","@Ca$e%"]', 'string[]', 'json')); } - public function testDeserializeInconsistentScalarArray() + public function testLegacyDeserializeInconsistentScalarArray() { $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([new ArrayDenormalizer()], ['json' => new JsonEncoder()]); $serializer->deserialize('["42"]', 'int[]', 'json'); } - public function testDeserializeWrappedScalar() + public function testLegacyDeserializeWrappedScalar() { $serializer = new Serializer([new UnwrappingDenormalizer()], ['json' => new JsonEncoder()]); $this->assertSame(42, $serializer->deserialize('{"wrapper": 42}', 'int', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[wrapper]'])); } + public function testDeserializeScalar() + { + $serializer = new Serializer([new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + + $this->assertSame(42, $serializer->deserialize('42', 'int', 'json')); + $this->assertSame(-42, $serializer->deserialize('-42', 'int', 'json')); + $this->assertTrue($serializer->deserialize('true', 'bool', 'json')); + $this->assertSame(3.14, $serializer->deserialize('3.14', 'float', 'json')); + $this->assertSame(3.14, $serializer->deserialize('31.4e-1', 'float', 'json')); + $this->assertSame(3.0, $serializer->deserialize('3', 'float', 'json')); // '3' === json_encode(3.0) + $this->assertSame(' spaces ', $serializer->deserialize('" spaces "', 'string', 'json')); + $this->assertSame('@Ca$e%', $serializer->deserialize('"@Ca$e%"', 'string', 'json')); + } + + public function testDeserializeLegacyScalarType() + { + $this->expectException(NotNormalizableValueException::class); + $serializer = new Serializer([new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + $serializer->deserialize('42', 'integer', 'json'); + } + + public function testDeserializeScalarTypeToCustomType() + { + $this->expectException(NotNormalizableValueException::class); + $serializer = new Serializer([new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + $serializer->deserialize('"something"', Foo::class, 'json'); + } + + public function testDeserializeNonscalarTypeToScalar() + { + $this->expectException(InvalidArgumentException::class); + $serializer = new Serializer([new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + $serializer->deserialize('{"foo":true}', 'string', 'json'); + } + + public function testDeserializeInconsistentScalarType() + { + $this->expectException(NotNormalizableValueException::class); + $serializer = new Serializer([new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + $serializer->deserialize('"42"', 'int', 'json'); + } + + public function testDeserializeScalarArray() + { + $serializer = new Serializer([new BuiltinTypeDenormalizer(), new ArrayDenormalizer()], ['json' => new JsonEncoder()]); + + $this->assertSame([42], $serializer->deserialize('[42]', 'int[]', 'json')); + $this->assertSame([true, false], $serializer->deserialize('[true,false]', 'bool[]', 'json')); + $this->assertSame([3.14, 3.24], $serializer->deserialize('[3.14,32.4e-1]', 'float[]', 'json')); + $this->assertSame([' spaces ', '@Ca$e%'], $serializer->deserialize('[" spaces ","@Ca$e%"]', 'string[]', 'json')); + } + + public function testDeserializeInconsistentScalarArray() + { + $this->expectException(NotNormalizableValueException::class); + $serializer = new Serializer([new BuiltinTypeDenormalizer(), new ArrayDenormalizer()], ['json' => new JsonEncoder()]); + $serializer->deserialize('["42"]', 'int[]', 'json'); + } + + public function testDeserializeWrappedScalar() + { + $serializer = new Serializer([new UnwrappingDenormalizer(), new BuiltinTypeDenormalizer()], ['json' => new JsonEncoder()]); + + $this->assertSame(42, $serializer->deserialize('{"wrapper": 42}', 'int', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[wrapper]'])); + } + private function serializerWithClassDiscriminator() { $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index 60b8b5c0542ef..77605e782c648 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -27,6 +27,7 @@ "symfony/cache": "^4.4|^5.0", "symfony/config": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0", + "symfony/deprecation-contracts": "^2.1", "symfony/error-handler": "^4.4|^5.0", "symfony/filesystem": "^4.4|^5.0", "symfony/form": "^4.4|^5.0", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy