diff --git a/UPGRADE-7.2.md b/UPGRADE-7.2.md index d4a43d9bc08df..cdc48a2f7b798 100644 --- a/UPGRADE-7.2.md +++ b/UPGRADE-7.2.md @@ -29,6 +29,11 @@ FrameworkBundle * [BC BREAK] The `secrets:decrypt-to-local` command terminates with a non-zero exit code when a secret could not be read +HttpKernel +---------- + + * Deprecate the `$type` parameter of `#[MapRequestPayload]`, use the TypeInfo component for automatic type detection instead + Messenger --------- diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 6710dabdab3e5..768839573ef4c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -46,6 +46,9 @@ ->tag('monolog.logger', ['channel' => 'request']) ->set('argument_metadata_factory', ArgumentMetadataFactory::class) + ->args([ + service('type_info.resolver')->nullOnInvalid(), + ]) ->set('argument_resolver', ArgumentResolver::class) ->args([ diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php index cf086380c03f0..182c22a2cec41 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php @@ -40,8 +40,13 @@ public function __construct( public readonly string|GroupSequence|array|null $validationGroups = null, string $resolver = RequestPayloadValueResolver::class, public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY, + /** @deprecated since Symfony 7.2 */ public readonly ?string $type = null, ) { + if ($type) { + trigger_deprecation('symfony/http-kernel', '7.2', 'The "type" parameter of the #[MapRequestPayload] attribute is deprecated and will be removed in Symfony 8.0. Try running "composer require symfony/type-info phpstan/phpdoc-parser" to get automatic type detection instead.'); + } + parent::__construct($resolver); } } diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index ed608d8f91485..3357b0e922610 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Remove `@internal` flag and add `@final` to `ServicesResetter` + * Deprecate the `$type` parameter of `#[MapRequestPayload]`, use the TypeInfo component for automatic type detection instead 7.1 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index 94d04bfe4ec28..8a27c09e90feb 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -83,6 +83,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable throw new \LogicException(\sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName())); } + // @deprecated since Symfony 7.2, remove the if statement in 8.0 if ($attribute instanceof MapRequestPayload) { if ('array' === $argument->getType()) { if (!$attribute->type) { @@ -202,6 +203,8 @@ private function mapRequestPayload(Request $request, ArgumentMetadata $argument, throw new UnsupportedMediaTypeHttpException(\sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format)); } + // @deprecated since Symfony 7.2. In 8.0, replace the whole if/else block by: + // $type = $argument->getType(); if ('array' === $argument->getType() && null !== $attribute->type) { $type = $attribute->type.'[]'; } else { diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php index 26b80f9dcf43a..5d173342a56e6 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php @@ -11,6 +11,9 @@ namespace Symfony\Component\HttpKernel\ControllerMetadata; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + /** * Builds {@see ArgumentMetadata} objects based on the given Controller. * @@ -18,6 +21,10 @@ */ final class ArgumentMetadataFactory implements ArgumentMetadataFactoryInterface { + public function __construct(private readonly ?TypeResolverInterface $typeResolver = null) + { + } + public function createArgumentMetadata(string|object|array $controller, ?\ReflectionFunctionAbstract $reflector = null): array { $arguments = []; @@ -46,6 +53,17 @@ private function getType(\ReflectionParameter $parameter): ?string if (!$type = $parameter->getType()) { return null; } + + if ($this->typeResolver) { + $type = $this->typeResolver->resolve($parameter); + + if ($type instanceof CollectionType) { + return (string) $type->getCollectionValueType().'[]'; + } + + return $type->getBaseType()->getTypeIdentifier()->value; + } + $name = $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type; return match (strtolower($name)) { diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 8b26767f9ea94..6285aef92cd81 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; @@ -40,6 +41,8 @@ class RequestPayloadValueResolverTest extends TestCase { + use ExpectDeprecationTrait; + public function testNotTypedArgument() { $resolver = new RequestPayloadValueResolver( @@ -423,7 +426,10 @@ public function testRequestInputValidationPassed() $this->assertEquals([$payload], $event->getArguments()); } - public function testRequestArrayDenormalization() + /** + * @group legacy + */ + public function testRequestArrayDenormalizationWithLegacyType() { $input = [ ['price' => '50'], @@ -443,6 +449,8 @@ public function testRequestArrayDenormalization() $resolver = new RequestPayloadValueResolver($serializer, $validator); + $this->expectDeprecation('Since symfony/http-kernel 7.2: The "type" parameter of the #[MapRequestPayload] attribute is deprecated and will be removed in Symfony 8.0. Try running "composer require symfony/type-info phpstan/phpdoc-parser" to get automatic type detection instead.'); + $argument = new ArgumentMetadata('prices', 'array', false, false, null, false, [ MapRequestPayload::class => new MapRequestPayload(type: RequestPayload::class), ]); @@ -457,6 +465,43 @@ public function testRequestArrayDenormalization() $this->assertEquals([$payload], $event->getArguments()); } + public function testRequestArrayDenormalization() + { + $input = [ + ['price' => '50'], + ['price' => '23'], + ]; + $payload = [ + new RequestPayload(50), + new RequestPayload(23), + ]; + + $serializer = new Serializer([new ArrayDenormalizer(), new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->willReturn(new ConstraintViolationList()); + + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('prices', RequestPayload::class.'[]', false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', $input); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertEquals([$payload], $event->getArguments()); + } + + /** + * @group legacy + */ public function testItThrowsOnMissingAttributeType() { $serializer = new Serializer(); @@ -474,6 +519,9 @@ public function testItThrowsOnMissingAttributeType() $resolver->resolve($request, $argument); } + /** + * @group legacy + */ public function testItThrowsOnInvalidAttributeTypeUsage() { $serializer = new Serializer(); diff --git a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php index 93fe699fcd48b..dfc1944227f6b 100644 --- a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentMetadataFactoryTest.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\BasicTypesController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\VariadicController; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; class ArgumentMetadataFactoryTest extends TestCase { @@ -151,6 +152,24 @@ public function testIssue41478() ], $arguments); } + public function testListOfObjectsWithTypeInfo() + { + $arguments = (new ArgumentMetadataFactory(TypeResolver::create()))->createArgumentMetadata([$this, 'listOfObjects']); + $this->assertEquals([ + new ArgumentMetadata('products', DummyProduct::class.'[]', false, false, null, false, [], controllerName: $this::class.'::listOfObjects'), + new ArgumentMetadata('bar', null, false, true, null, controllerName: $this::class.'::listOfObjects'), + ], $arguments); + } + + public function testListOfObjectsWithoutTypeInfo() + { + $arguments = $this->factory->createArgumentMetadata([$this, 'listOfObjects']); + $this->assertEquals([ + new ArgumentMetadata('products', 'array', false, false, null, false, [], controllerName: $this::class.'::listOfObjects'), + new ArgumentMetadata('bar', null, false, true, null, controllerName: $this::class.'::listOfObjects'), + ], $arguments); + } + public function signature1(self $foo, array $bar, callable $baz) { } @@ -170,4 +189,16 @@ public function signature4($foo = 'default', $bar = 500, $baz = []) public function signature5(?array $foo = null, $bar = null) { } + + /** + * @param DummyProduct[] $products + */ + public function listOfObjects(array $products, $bar = null) + { + } +} + +class DummyProduct +{ + public $price; } diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index 8e54c82c9ae02..7628130ec03b3 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -25,6 +25,7 @@ "psr/log": "^1|^2|^3" }, "require-dev": { + "phpstan/phpdoc-parser": "^1.0", "symfony/browser-kit": "^6.4|^7.0", "symfony/clock": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", @@ -42,6 +43,7 @@ "symfony/stopwatch": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.2", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -67,6 +69,7 @@ "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", "symfony/twig-bridge": "<6.4", + "symfony/type-info": "<7.2", "symfony/validator": "<6.4", "symfony/var-dumper": "<6.4", "twig/twig": "<3.0.4"