From 245485c2a778330578f6c90aaea573336831620a Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 17 Feb 2023 10:25:23 +0100 Subject: [PATCH] [HttpKernel] Introduce pinnable value resolvers with `#[ValueResolver]` and `#[AsPinnedValueResolver]` --- .../Bridge/Doctrine/Attribute/MapEntity.php | 9 +- src/Symfony/Bridge/Doctrine/composer.json | 2 +- .../FrameworkExtension.php | 5 + .../FrameworkBundle/Resources/config/web.php | 19 +-- .../Resources/config/security.php | 2 +- .../Attribute/AsPinnedValueResolver.php | 24 ++++ .../HttpKernel/Attribute/MapDateTime.php | 9 +- .../HttpKernel/Attribute/ValueResolver.php | 27 ++++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../Controller/ArgumentResolver.php | 60 ++++++-- .../ControllerArgumentValueResolverPass.php | 21 ++- .../Exception/ResolverNotFoundException.php | 33 +++++ .../Tests/Controller/ArgumentResolverTest.php | 133 +++++++++++++----- .../Security/Http/Attribute/CurrentUser.php | 9 +- .../Component/Security/Http/composer.json | 2 +- 15 files changed, 293 insertions(+), 63 deletions(-) create mode 100644 src/Symfony/Component/HttpKernel/Attribute/AsPinnedValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Attribute/ValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Exception/ResolverNotFoundException.php diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php index 74caf14c9af55..529bf05dc7767 100644 --- a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php @@ -11,11 +11,14 @@ namespace Symfony\Bridge\Doctrine\Attribute; +use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; + /** * Indicates that a controller argument should receive an Entity. */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -class MapEntity +class MapEntity extends ValueResolver { public function __construct( public ?string $class = null, @@ -26,8 +29,10 @@ public function __construct( public ?bool $stripNull = null, public array|string|null $id = null, public ?bool $evictCache = null, - public bool $disabled = false, + bool $disabled = false, + string $resolver = EntityValueResolver::class, ) { + parent::__construct($resolver, $disabled); } public function withDefaults(self $defaults, ?string $class): static diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index e0730a10101a9..b3462366da194 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -30,7 +30,7 @@ "symfony/config": "^5.4|^6.0", "symfony/dependency-injection": "^6.2", "symfony/form": "^5.4.21|^6.2.7", - "symfony/http-kernel": "^6.2", + "symfony/http-kernel": "^6.3", "symfony/messenger": "^5.4|^6.0", "symfony/doctrine-messenger": "^5.4|^6.0", "symfony/property-access": "^5.4|^6.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 618cefb128d62..886e8a3b514ff 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -83,6 +83,7 @@ use Symfony\Component\HttpClient\UriTemplateHttpClient; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Attribute\AsPinnedValueResolver; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; @@ -691,6 +692,10 @@ public function load(array $configs, ContainerBuilder $container) $definition->addTag('messenger.message_handler', $tagAttributes); }); + $container->registerAttributeForAutoconfiguration(AsPinnedValueResolver::class, static function (ChildDefinition $definition, AsPinnedValueResolver $attribute): void { + $definition->addTag('controller.pinned_value_resolver', $attribute->name ? ['name' => $attribute->name] : []); + }); + if (!$container->getParameter('kernel.debug')) { // remove tagged iterator argument for resource checkers $container->getDefinition('config_cache_factory')->setArguments([]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index cd4cdffa10ade..ca9128b3d0c0a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -46,40 +46,41 @@ ->args([ service('argument_metadata_factory'), abstract_arg('argument value resolvers'), + abstract_arg('pinned value resolvers'), ]) ->set('argument_resolver.backed_enum_resolver', BackedEnumValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => 100]) + ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => BackedEnumValueResolver::class]) ->set('argument_resolver.uid', UidValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => 100]) + ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => UidValueResolver::class]) ->set('argument_resolver.datetime', DateTimeValueResolver::class) ->args([ service('clock')->nullOnInvalid(), ]) - ->tag('controller.argument_value_resolver', ['priority' => 100]) + ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => DateTimeValueResolver::class]) ->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => 100]) + ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => RequestAttributeValueResolver::class]) ->set('argument_resolver.request', RequestValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => 50]) + ->tag('controller.argument_value_resolver', ['priority' => 50, 'name' => RequestValueResolver::class]) ->set('argument_resolver.session', SessionValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => 50]) + ->tag('controller.argument_value_resolver', ['priority' => 50, 'name' => SessionValueResolver::class]) ->set('argument_resolver.service', ServiceValueResolver::class) ->args([ abstract_arg('service locator, set in RegisterControllerArgumentLocatorsPass'), ]) - ->tag('controller.argument_value_resolver', ['priority' => -50]) + ->tag('controller.argument_value_resolver', ['priority' => -50, 'name' => ServiceValueResolver::class]) ->set('argument_resolver.default', DefaultValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => -100]) + ->tag('controller.argument_value_resolver', ['priority' => -100, 'name' => DefaultValueResolver::class]) ->set('argument_resolver.variadic', VariadicValueResolver::class) - ->tag('controller.argument_value_resolver', ['priority' => -150]) + ->tag('controller.argument_value_resolver', ['priority' => -150, 'name' => VariadicValueResolver::class]) ->set('response_listener', ResponseListener::class) ->args([ diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 5f4e693b85cbb..fa7c0fe37e8eb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -100,7 +100,7 @@ ->args([ service('security.token_storage'), ]) - ->tag('controller.argument_value_resolver', ['priority' => 120]) + ->tag('controller.argument_value_resolver', ['priority' => 120, 'name' => UserValueResolver::class]) // Authentication related services ->set('security.authentication.trust_resolver', AuthenticationTrustResolver::class) diff --git a/src/Symfony/Component/HttpKernel/Attribute/AsPinnedValueResolver.php b/src/Symfony/Component/HttpKernel/Attribute/AsPinnedValueResolver.php new file mode 100644 index 0000000000000..e529294123e14 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/AsPinnedValueResolver.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Service tag to autoconfigure pinned value resolvers. + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +class AsPinnedValueResolver +{ + public function __construct( + public readonly ?string $name = null, + ) { + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapDateTime.php b/src/Symfony/Component/HttpKernel/Attribute/MapDateTime.php index ce9f8568553dc..bfe48a809095d 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapDateTime.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapDateTime.php @@ -11,14 +11,19 @@ namespace Symfony\Component\HttpKernel\Attribute; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DateTimeValueResolver; + /** * Controller parameter tag to configure DateTime arguments. */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -class MapDateTime +class MapDateTime extends ValueResolver { public function __construct( - public readonly ?string $format = null + public readonly ?string $format = null, + bool $disabled = false, + string $resolver = DateTimeValueResolver::class, ) { + parent::__construct($resolver, $disabled); } } diff --git a/src/Symfony/Component/HttpKernel/Attribute/ValueResolver.php b/src/Symfony/Component/HttpKernel/Attribute/ValueResolver.php new file mode 100644 index 0000000000000..df4aaff5d746d --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/ValueResolver.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; + +#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] +class ValueResolver +{ + /** + * @param class-string|string $name + */ + public function __construct( + public string $name, + public bool $disabled = false, + ) { + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index d5bf22a1d967c..46a5463e024c3 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Use an instance of `Psr\Clock\ClockInterface` to generate the current date time in `DateTimeValueResolver` * Add `#[WithLogLevel]` for defining log levels for exceptions * Add `skip_response_headers` to the `HttpCache` options + * Introduce pinnable value resolvers with `#[ValueResolver]` and `#[AsPinnedValueResolver]` 6.2 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php index 9930cc08bfb77..2cd9ec1c324be 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php @@ -11,7 +11,9 @@ namespace Symfony\Component\HttpKernel\Controller; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; @@ -20,6 +22,8 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface; +use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException; +use Symfony\Contracts\Service\ServiceProviderInterface; /** * Responsible for resolving the arguments passed to an action. @@ -30,14 +34,16 @@ final class ArgumentResolver implements ArgumentResolverInterface { private ArgumentMetadataFactoryInterface $argumentMetadataFactory; private iterable $argumentValueResolvers; + private ?ContainerInterface $namedResolvers; /** * @param iterable $argumentValueResolvers */ - public function __construct(ArgumentMetadataFactoryInterface $argumentMetadataFactory = null, iterable $argumentValueResolvers = []) + public function __construct(ArgumentMetadataFactoryInterface $argumentMetadataFactory = null, iterable $argumentValueResolvers = [], ContainerInterface $namedResolvers = null) { $this->argumentMetadataFactory = $argumentMetadataFactory ?? new ArgumentMetadataFactory(); $this->argumentValueResolvers = $argumentValueResolvers ?: self::getDefaultArgumentValueResolvers(); + $this->namedResolvers = $namedResolvers; } public function getArguments(Request $request, callable $controller, \ReflectionFunctionAbstract $reflector = null): array @@ -45,10 +51,37 @@ public function getArguments(Request $request, callable $controller, \Reflection $arguments = []; foreach ($this->argumentMetadataFactory->createArgumentMetadata($controller, $reflector) as $metadata) { - foreach ($this->argumentValueResolvers as $resolver) { + $argumentValueResolvers = $this->argumentValueResolvers; + $disabledResolvers = []; + + if ($this->namedResolvers && $attributes = $metadata->getAttributesOfType(ValueResolver::class, $metadata::IS_INSTANCEOF)) { + $resolverName = null; + foreach ($attributes as $attribute) { + if ($attribute->disabled) { + $disabledResolvers[$attribute->name] = true; + } elseif ($resolverName) { + throw new \LogicException(sprintf('You can only pin one resolver per argument, but argument "$%s" of "%s()" has more.', $metadata->getName(), $this->getPrettyName($controller))); + } else { + $resolverName = $attribute->name; + } + } + + if ($resolverName) { + if (!$this->namedResolvers->has($resolverName)) { + throw new ResolverNotFoundException($resolverName, $this->namedResolvers instanceof ServiceProviderInterface ? array_keys($this->namedResolvers->getProvidedServices()) : []); + } + + $argumentValueResolvers = [$this->namedResolvers->get($resolverName)]; + } + } + + foreach ($argumentValueResolvers as $name => $resolver) { if ((!$resolver instanceof ValueResolverInterface || $resolver instanceof TraceableValueResolver) && !$resolver->supports($request, $metadata)) { continue; } + if (isset($disabledResolvers[\is_int($name) ? $resolver::class : $name])) { + continue; + } $count = 0; foreach ($resolver->resolve($request, $metadata) as $argument) { @@ -70,15 +103,7 @@ public function getArguments(Request $request, callable $controller, \Reflection } } - $representative = $controller; - - if (\is_array($representative)) { - $representative = sprintf('%s::%s()', $representative[0]::class, $representative[1]); - } elseif (\is_object($representative)) { - $representative = get_debug_type($representative); - } - - throw new \RuntimeException(sprintf('Controller "%s" requires that you provide a value for the "$%s" argument. Either the argument is nullable and no null value has been provided, no default value has been provided or because there is a non optional argument after this one.', $representative, $metadata->getName())); + throw new \RuntimeException(sprintf('Controller "%s" requires that you provide a value for the "$%s" argument. Either the argument is nullable and no null value has been provided, no default value has been provided or because there is a non optional argument after this one.', $this->getPrettyName($controller), $metadata->getName())); } return $arguments; @@ -97,4 +122,17 @@ public static function getDefaultArgumentValueResolvers(): iterable new VariadicValueResolver(), ]; } + + private function getPrettyName($controller): string + { + if (\is_array($controller)) { + return $controller[0]::class.'::'.$controller[1]; + } + + if (\is_object($controller)) { + return get_debug_type($controller); + } + + return $controller; + } } diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/ControllerArgumentValueResolverPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/ControllerArgumentValueResolverPass.php index 5bb801c8cde30..6e00840c7e08a 100644 --- a/src/Symfony/Component/HttpKernel/DependencyInjection/ControllerArgumentValueResolverPass.php +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/ControllerArgumentValueResolverPass.php @@ -12,6 +12,8 @@ namespace Symfony\Component\HttpKernel\DependencyInjection; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -37,10 +39,24 @@ public function process(ContainerBuilder $container) return; } - $resolvers = $this->findAndSortTaggedServices('controller.argument_value_resolver', $container); + $definitions = $container->getDefinitions(); + $namedResolvers = $this->findAndSortTaggedServices(new TaggedIteratorArgument('controller.pinned_value_resolver', 'name', needsIndexes: true), $container); + $resolvers = $this->findAndSortTaggedServices(new TaggedIteratorArgument('controller.argument_value_resolver', 'name', needsIndexes: true), $container); + + foreach ($resolvers as $name => $resolverReference) { + $id = (string) $resolverReference; + + if ($definitions[$id]->hasTag('controller.pinned_value_resolver')) { + unset($resolvers[$name]); + } else { + $namedResolvers[$name] ??= clone $resolverReference; + } + } + + $resolvers = array_values($resolvers); if ($container->getParameter('kernel.debug') && class_exists(Stopwatch::class) && $container->has('debug.stopwatch')) { - foreach ($resolvers as $resolverReference) { + foreach ($resolvers + $namedResolvers as $resolverReference) { $id = (string) $resolverReference; $container->register("debug.$id", TraceableValueResolver::class) ->setDecoratedService($id) @@ -51,6 +67,7 @@ public function process(ContainerBuilder $container) $container ->getDefinition('argument_resolver') ->replaceArgument(1, new IteratorArgument($resolvers)) + ->setArgument(2, new ServiceLocatorArgument($namedResolvers)) ; } } diff --git a/src/Symfony/Component/HttpKernel/Exception/ResolverNotFoundException.php b/src/Symfony/Component/HttpKernel/Exception/ResolverNotFoundException.php new file mode 100644 index 0000000000000..6d9fb8a01f46c --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/ResolverNotFoundException.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Exception; + +class ResolverNotFoundException extends \RuntimeException +{ + /** + * @param string[] $alternatives + */ + public function __construct(string $name, array $alternatives = []) + { + $msg = sprintf('You have requested a non-existent resolver "%s".', $name); + if ($alternatives) { + if (1 === \count($alternatives)) { + $msg .= ' Did you mean this: "'; + } else { + $msg .= ' Did you mean one of these: "'; + } + $msg .= implode('", "', $alternatives).'"?'; + } + + parent::__construct($msg); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php index 65ff564f1a90b..22d2d9cbb5480 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php @@ -12,14 +12,18 @@ namespace Symfony\Component\HttpKernel\Tests\Controller; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingRequest; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingSession; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController; @@ -27,20 +31,19 @@ class ArgumentResolverTest extends TestCase { - /** @var ArgumentResolver */ - private static $resolver; - - public static function setUpBeforeClass(): void + public static function getResolver(array $chainableResolvers = [], array $namedResolvers = null): ArgumentResolver { - $factory = new ArgumentMetadataFactory(); + if (null !== $namedResolvers) { + $namedResolvers = new ServiceLocator(array_map(fn ($resolver) => fn () => $resolver, $namedResolvers)); + } - self::$resolver = new ArgumentResolver($factory); + return new ArgumentResolver(new ArgumentMetadataFactory(), $chainableResolvers, $namedResolvers); } public function testDefaultState() { - $this->assertEquals(self::$resolver, new ArgumentResolver()); - $this->assertNotEquals(self::$resolver, new ArgumentResolver(null, [new RequestAttributeValueResolver()])); + $this->assertEquals(self::getResolver(), new ArgumentResolver()); + $this->assertNotEquals(self::getResolver(), new ArgumentResolver(null, [new RequestAttributeValueResolver()])); } public function testGetArguments() @@ -49,7 +52,7 @@ public function testGetArguments() $request->attributes->set('foo', 'foo'); $controller = [new self(), 'controllerWithFoo']; - $this->assertEquals(['foo'], self::$resolver->getArguments($request, $controller), '->getArguments() returns an array of arguments for the controller method'); + $this->assertEquals(['foo'], self::getResolver()->getArguments($request, $controller), '->getArguments() returns an array of arguments for the controller method'); } public function testGetArgumentsReturnsEmptyArrayWhenNoArguments() @@ -57,7 +60,7 @@ public function testGetArgumentsReturnsEmptyArrayWhenNoArguments() $request = Request::create('/'); $controller = [new self(), 'controllerWithoutArguments']; - $this->assertEquals([], self::$resolver->getArguments($request, $controller), '->getArguments() returns an empty array if the method takes no arguments'); + $this->assertEquals([], self::getResolver()->getArguments($request, $controller), '->getArguments() returns an empty array if the method takes no arguments'); } public function testGetArgumentsUsesDefaultValue() @@ -66,7 +69,7 @@ public function testGetArgumentsUsesDefaultValue() $request->attributes->set('foo', 'foo'); $controller = [new self(), 'controllerWithFooAndDefaultBar']; - $this->assertEquals(['foo', null], self::$resolver->getArguments($request, $controller), '->getArguments() uses default values if present'); + $this->assertEquals(['foo', null], self::getResolver()->getArguments($request, $controller), '->getArguments() uses default values if present'); } public function testGetArgumentsOverrideDefaultValueByRequestAttribute() @@ -76,7 +79,7 @@ public function testGetArgumentsOverrideDefaultValueByRequestAttribute() $request->attributes->set('bar', 'bar'); $controller = [new self(), 'controllerWithFooAndDefaultBar']; - $this->assertEquals(['foo', 'bar'], self::$resolver->getArguments($request, $controller), '->getArguments() overrides default values if provided in the request attributes'); + $this->assertEquals(['foo', 'bar'], self::getResolver()->getArguments($request, $controller), '->getArguments() overrides default values if provided in the request attributes'); } public function testGetArgumentsFromClosure() @@ -85,7 +88,7 @@ public function testGetArgumentsFromClosure() $request->attributes->set('foo', 'foo'); $controller = function ($foo) {}; - $this->assertEquals(['foo'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo'], self::getResolver()->getArguments($request, $controller)); } public function testGetArgumentsUsesDefaultValueFromClosure() @@ -94,7 +97,7 @@ public function testGetArgumentsUsesDefaultValueFromClosure() $request->attributes->set('foo', 'foo'); $controller = function ($foo, $bar = 'bar') {}; - $this->assertEquals(['foo', 'bar'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', 'bar'], self::getResolver()->getArguments($request, $controller)); } public function testGetArgumentsFromInvokableObject() @@ -103,12 +106,12 @@ public function testGetArgumentsFromInvokableObject() $request->attributes->set('foo', 'foo'); $controller = new self(); - $this->assertEquals(['foo', null], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', null], self::getResolver()->getArguments($request, $controller)); // Test default bar overridden by request attribute $request->attributes->set('bar', 'bar'); - $this->assertEquals(['foo', 'bar'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', 'bar'], self::getResolver()->getArguments($request, $controller)); } public function testGetArgumentsFromFunctionName() @@ -118,7 +121,7 @@ public function testGetArgumentsFromFunctionName() $request->attributes->set('foobar', 'foobar'); $controller = __NAMESPACE__.'\controller_function'; - $this->assertEquals(['foo', 'foobar'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', 'foobar'], self::getResolver()->getArguments($request, $controller)); } public function testGetArgumentsFailsOnUnresolvedValue() @@ -129,7 +132,7 @@ public function testGetArgumentsFailsOnUnresolvedValue() $controller = [new self(), 'controllerWithFooBarFoobar']; try { - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); $this->fail('->getArguments() throws a \RuntimeException exception if it cannot determine the argument value'); } catch (\Exception $e) { $this->assertInstanceOf(\RuntimeException::class, $e, '->getArguments() throws a \RuntimeException exception if it cannot determine the argument value'); @@ -141,7 +144,7 @@ public function testGetArgumentsInjectsRequest() $request = Request::create('/'); $controller = [new self(), 'controllerWithRequest']; - $this->assertEquals([$request], self::$resolver->getArguments($request, $controller), '->getArguments() injects the request'); + $this->assertEquals([$request], self::getResolver()->getArguments($request, $controller), '->getArguments() injects the request'); } public function testGetArgumentsInjectsExtendingRequest() @@ -149,7 +152,7 @@ public function testGetArgumentsInjectsExtendingRequest() $request = ExtendingRequest::create('/'); $controller = [new self(), 'controllerWithExtendingRequest']; - $this->assertEquals([$request], self::$resolver->getArguments($request, $controller), '->getArguments() injects the request when extended'); + $this->assertEquals([$request], self::getResolver()->getArguments($request, $controller), '->getArguments() injects the request when extended'); } public function testGetVariadicArguments() @@ -159,7 +162,7 @@ public function testGetVariadicArguments() $request->attributes->set('bar', ['foo', 'bar']); $controller = [new VariadicController(), 'action']; - $this->assertEquals(['foo', 'foo', 'bar'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', 'foo', 'bar'], self::getResolver()->getArguments($request, $controller)); } public function testGetVariadicArgumentsWithoutArrayInRequest() @@ -170,7 +173,7 @@ public function testGetVariadicArgumentsWithoutArrayInRequest() $request->attributes->set('bar', 'foo'); $controller = [new VariadicController(), 'action']; - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); } /** @@ -179,9 +182,8 @@ public function testGetVariadicArgumentsWithoutArrayInRequest() public function testGetArgumentWithoutArray() { $this->expectException(\InvalidArgumentException::class); - $factory = new ArgumentMetadataFactory(); $valueResolver = $this->createMock(ArgumentValueResolverInterface::class); - $resolver = new ArgumentResolver($factory, [$valueResolver]); + $resolver = self::getResolver([$valueResolver]); $valueResolver->expects($this->any())->method('supports')->willReturn(true); $valueResolver->expects($this->any())->method('resolve')->willReturn([]); @@ -199,7 +201,7 @@ public function testIfExceptionIsThrownWhenMissingAnArgument() $request = Request::create('/'); $controller = $this->controllerWithFoo(...); - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); } public function testGetNullableArguments() @@ -210,7 +212,7 @@ public function testGetNullableArguments() $request->attributes->set('last', 'last'); $controller = [new NullableController(), 'action']; - $this->assertEquals(['foo', new \stdClass(), 'value', 'last'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals(['foo', new \stdClass(), 'value', 'last'], self::getResolver()->getArguments($request, $controller)); } public function testGetNullableArgumentsWithDefaults() @@ -219,7 +221,7 @@ public function testGetNullableArgumentsWithDefaults() $request->attributes->set('last', 'last'); $controller = [new NullableController(), 'action']; - $this->assertEquals([null, null, 'value', 'last'], self::$resolver->getArguments($request, $controller)); + $this->assertEquals([null, null, 'value', 'last'], self::getResolver()->getArguments($request, $controller)); } public function testGetSessionArguments() @@ -229,7 +231,7 @@ public function testGetSessionArguments() $request->setSession($session); $controller = $this->controllerWithSession(...); - $this->assertEquals([$session], self::$resolver->getArguments($request, $controller)); + $this->assertEquals([$session], self::getResolver()->getArguments($request, $controller)); } public function testGetSessionArgumentsWithExtendedSession() @@ -239,7 +241,7 @@ public function testGetSessionArgumentsWithExtendedSession() $request->setSession($session); $controller = $this->controllerWithExtendingSession(...); - $this->assertEquals([$session], self::$resolver->getArguments($request, $controller)); + $this->assertEquals([$session], self::getResolver()->getArguments($request, $controller)); } public function testGetSessionArgumentsWithInterface() @@ -249,7 +251,7 @@ public function testGetSessionArgumentsWithInterface() $request->setSession($session); $controller = $this->controllerWithSessionInterface(...); - $this->assertEquals([$session], self::$resolver->getArguments($request, $controller)); + $this->assertEquals([$session], self::getResolver()->getArguments($request, $controller)); } public function testGetSessionMissMatchWithInterface() @@ -260,7 +262,7 @@ public function testGetSessionMissMatchWithInterface() $request->setSession($session); $controller = $this->controllerWithExtendingSession(...); - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); } public function testGetSessionMissMatchWithImplementation() @@ -271,7 +273,7 @@ public function testGetSessionMissMatchWithImplementation() $request->setSession($session); $controller = $this->controllerWithExtendingSession(...); - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); } public function testGetSessionMissMatchOnNull() @@ -280,7 +282,51 @@ public function testGetSessionMissMatchOnNull() $request = Request::create('/'); $controller = $this->controllerWithExtendingSession(...); - self::$resolver->getArguments($request, $controller); + self::getResolver()->getArguments($request, $controller); + } + + public function testPinnedResolver() + { + $resolver = self::getResolver([], [DefaultValueResolver::class => new DefaultValueResolver()]); + + $request = Request::create('/'); + $request->attributes->set('foo', 'bar'); + $controller = $this->controllerPinningResolver(...); + + $this->assertSame([1], $resolver->getArguments($request, $controller)); + } + + public function testDisabledResolver() + { + $resolver = self::getResolver(namedResolvers: []); + + $request = Request::create('/'); + $request->attributes->set('foo', 'bar'); + $controller = $this->controllerDisablingResolver(...); + + $this->assertSame([1], $resolver->getArguments($request, $controller)); + } + + public function testManyPinnedResolvers() + { + $resolver = self::getResolver(namedResolvers: []); + + $request = Request::create('/'); + $controller = $this->controllerPinningManyResolvers(...); + + $this->expectException(\LogicException::class); + $resolver->getArguments($request, $controller); + } + + public function testUnknownPinnedResolver() + { + $resolver = self::getResolver(namedResolvers: []); + + $request = Request::create('/'); + $controller = $this->controllerPinningUnknownResolver(...); + + $this->expectException(ResolverNotFoundException::class); + $resolver->getArguments($request, $controller); } public function __invoke($foo, $bar = null) @@ -322,6 +368,27 @@ public function controllerWithSessionInterface(SessionInterface $session) public function controllerWithExtendingSession(ExtendingSession $session) { } + + public function controllerPinningResolver(#[ValueResolver(DefaultValueResolver::class)] int $foo = 1) + { + } + + public function controllerDisablingResolver(#[ValueResolver(RequestAttributeValueResolver::class, disabled: true)] int $foo = 1) + { + } + + public function controllerPinningManyResolvers( + #[ValueResolver(RequestAttributeValueResolver::class)] + #[ValueResolver(DefaultValueResolver::class)] + int $foo + ) { + } + + public function controllerPinningUnknownResolver( + #[ValueResolver('foo')] + int $bar + ) { + } } function controller_function($foo, $foobar) diff --git a/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php index 413f982ecc5ac..bcf996072e3bf 100644 --- a/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php +++ b/src/Symfony/Component/Security/Http/Attribute/CurrentUser.php @@ -11,10 +11,17 @@ namespace Symfony\Component\Security\Http\Attribute; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; +use Symfony\Component\Security\Http\Controller\UserValueResolver; + /** * Indicates that a controller argument should receive the current logged user. */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -class CurrentUser +class CurrentUser extends ValueResolver { + public function __construct(bool $disabled = false, string $resolver = UserValueResolver::class) + { + parent::__construct($resolver, $disabled); + } } diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 53b5c26f40398..e93e81bee6f1d 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -20,7 +20,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/security-core": "~6.0.19|~6.1.11|^6.2.5", "symfony/http-foundation": "^5.4|^6.0", - "symfony/http-kernel": "^6.2", + "symfony/http-kernel": "^6.3", "symfony/polyfill-mbstring": "~1.0", "symfony/property-access": "^5.4|^6.0" },