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