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",