diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index efa78dfb05878..1335411a64487 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -166,7 +166,7 @@ private function mapQueryString(Request $request, string $type, MapQueryString $ return null; } - return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); + return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ['filter_bool' => true]); } private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): ?object @@ -180,7 +180,7 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay } if ($data = $request->request->all()) { - return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); + return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ('form' === $format ? ['filter_bool' => true] : [])); } if ('' === $data = $request->getContent()) { diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 1273b4a6203c8..4825ad890facd 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -722,6 +722,88 @@ public function testRequestPayloadValidationErrorCustomStatusCode() $this->assertSame('This value should be of type string.', $validationFailedException->getViolations()[0]->getMessage()); } } + + /** + * @dataProvider provideBoolArgument + */ + public function testBoolArgumentInQueryString(mixed $expectedValue, ?string $parameterValue) + { + $serializer = new Serializer([new ObjectNormalizer()]); + $validator = $this->createMock(ValidatorInterface::class); + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('filtered', ObjectWithBoolArgument::class, false, false, null, false, [ + MapQueryString::class => new MapQueryString(), + ]); + $request = Request::create('/', 'GET', ['value' => $parameterValue]); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertSame($expectedValue, $event->getArguments()[0]->value); + } + + /** + * @dataProvider provideBoolArgument + */ + public function testBoolArgumentInBody(mixed $expectedValue, ?string $parameterValue) + { + $serializer = new Serializer([new ObjectNormalizer()]); + $validator = $this->createMock(ValidatorInterface::class); + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('filtered', ObjectWithBoolArgument::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', ['value' => $parameterValue], server: ['CONTENT_TYPE' => 'multipart/form-data']); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertSame($expectedValue, $event->getArguments()[0]->value); + } + + public static function provideBoolArgument() + { + yield 'default value' => [null, null]; + yield '"0"' => [false, '0']; + yield '"false"' => [false, 'false']; + yield '"no"' => [false, 'no']; + yield '"off"' => [false, 'off']; + yield '"1"' => [true, '1']; + yield '"true"' => [true, 'true']; + yield '"yes"' => [true, 'yes']; + yield '"on"' => [true, 'on']; + } + + /** + * Boolean filtering must be disabled for content types other than form data. + */ + public function testBoolArgumentInJsonBody() + { + $serializer = new Serializer([new ObjectNormalizer()]); + $validator = $this->createMock(ValidatorInterface::class); + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('filtered', ObjectWithBoolArgument::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', ['value' => 'off'], server: ['CONTENT_TYPE' => 'application/json']); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertTrue($event->getArguments()[0]->value); + } } class RequestPayload @@ -765,3 +847,10 @@ public function getPassword(): string return $this->password; } } + +class ObjectWithBoolArgument +{ + public function __construct(public readonly ?bool $value = null) + { + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index a5cb2e777acc2..88a42d4e8d578 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `DateTimeNormalizer::CAST_KEY` context option * Add `Default` and "class name" default groups + * Add `AbstractNormalizer::FILTER_BOOL` context option 7.0 --- diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index bd3171b1742c0..93c367b7d80e5 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -118,6 +118,16 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn */ public const REQUIRE_ALL_PROPERTIES = 'require_all_properties'; + /** + * Flag to control whether a non-boolean value should be filtered using the + * filter_var function with the {@see https://www.php.net/manual/fr/filter.filters.validate.php} + * \FILTER_VALIDATE_BOOL filter before casting it to a boolean. + * + * "0", "false", "off", "no" and "" will be cast to false. + * "1", "true", "on" and "yes" will be cast to true. + */ + public const FILTER_BOOL = 'filter_bool'; + /** * @internal */ @@ -436,12 +446,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex unset($context['has_constructor']); if (!$reflectionClass->isInstantiable()) { - throw NotNormalizableValueException::createForUnexpectedDataType( - sprintf('Failed to create object because the class "%s" is not instantiable.', $class), - $data, - ['unknown'], - $context['deserialization_path'] ?? null - ); + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Failed to create object because the class "%s" is not instantiable.', $class), $data, ['unknown'], $context['deserialization_path'] ?? null); } return new $class(); @@ -473,7 +478,9 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara return null; } - return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + + return $this->applyFilterBool($parameter, $parameterData, $context); } /** @@ -524,6 +531,19 @@ final protected function applyCallbacks(mixed $value, object|string $object, str return $callback ? $callback($value, $object, $attribute, $format, $context) : $value; } + final protected function applyFilterBool(\ReflectionParameter $parameter, mixed $value, array $context): mixed + { + if (!($context[self::FILTER_BOOL] ?? false)) { + return $value; + } + + if (!($parameterType = $parameter->getType()) instanceof \ReflectionNamedType || 'bool' !== $parameterType->getName()) { + return $value; + } + + return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) ?? $value; + } + /** * Computes the normalization context merged with current one. Metadata always wins over global context, as more specific. * diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 4ca328d591beb..1c2f52fcd45c3 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -601,7 +601,9 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara $parameterData = $this->validateAndDenormalize($types, $class->getName(), $parameterName, $parameterData, $format, $context); - return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + + return $this->applyFilterBool($parameter, $parameterData, $context); } /** diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolObject.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolObject.php new file mode 100644 index 0000000000000..2d9828b915711 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolObject.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer\Features; + +class FilterBoolObject +{ + public function __construct(public ?bool $value) + { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolTestTrait.php new file mode 100644 index 0000000000000..ceb80dc3b71be --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/FilterBoolTestTrait.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer\Features; + +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Test AbstractNormalizer::FILTER_BOOL. + */ +trait FilterBoolTestTrait +{ + abstract protected function getNormalizerForFilterBool(): DenormalizerInterface; + + /** + * @dataProvider provideObjectWithBoolArguments + */ + public function testObjectWithBoolArguments(?bool $expectedValue, ?string $parameterValue) + { + $normalizer = $this->getNormalizerForFilterBool(); + + $dummy = $normalizer->denormalize(['value' => $parameterValue], FilterBoolObject::class, context: ['filter_bool' => true]); + + $this->assertSame($expectedValue, $dummy->value); + } + + public static function provideObjectWithBoolArguments() + { + yield 'default value' => [null, null]; + yield '0' => [false, '0']; + yield 'false' => [false, 'false']; + yield 'no' => [false, 'no']; + yield 'off' => [false, 'off']; + yield '1' => [true, '1']; + yield 'true' => [true, 'true']; + yield 'yes' => [true, 'yes']; + yield 'on' => [true, 'on']; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php index 8000dea19fe0f..c5fb731000396 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -39,6 +39,7 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -53,6 +54,7 @@ class GetSetMethodNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -279,6 +281,11 @@ protected function getDenormalizerForGroups(): GetSetMethodNormalizer return new GetSetMethodNormalizer($classMetadataFactory); } + protected function getNormalizerForFilterBool(): GetSetMethodNormalizer + { + return new GetSetMethodNormalizer(); + } + public function testGroupsNormalizeWithNameConverter() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index fa47995da7882..9994a27e545c9 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -50,6 +50,7 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ContextMetadataTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -72,6 +73,7 @@ class ObjectNormalizerTest extends TestCase use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; use ContextMetadataTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -345,6 +347,11 @@ protected function getDenormalizerForAttributes(): ObjectNormalizer return $normalizer; } + protected function getNormalizerForFilterBool(): ObjectNormalizer + { + return new ObjectNormalizer(); + } + public function testAttributesContextDenormalizeConstructor() { $normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor()); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php index 04a9afaf8bde0..b93a7bb9fd1cd 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php @@ -38,6 +38,7 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -52,6 +53,7 @@ class PropertyNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -259,6 +261,11 @@ protected function getSelfReferencingModel() return new PropertyCircularReferenceDummy(); } + protected function getNormalizerForFilterBool(): PropertyNormalizer + { + return new PropertyNormalizer(); + } + public function testSiblingReference() { $serializer = new Serializer([$this->normalizer]);