diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index a4e975dac8749..0d526b03e1d55 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionParameterValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; @@ -101,6 +102,9 @@ ->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class) ->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class]) + ->set('argument_resolver.session_parameter_value_resolver', SessionParameterValueResolver::class) + ->tag('controller.targeted_value_resolver', ['name' => SessionParameterValueResolver::class]) + ->set('response_listener', ResponseListener::class) ->args([ param('kernel.charset'), diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 2ecedbc45660e..95ebcd3cc4eef 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -26,7 +26,7 @@ "symfony/error-handler": "^7.3", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^7.3", - "symfony/http-kernel": "^7.2", + "symfony/http-kernel": "^7.3", "symfony/polyfill-mbstring": "~1.0", "symfony/filesystem": "^7.1", "symfony/finder": "^6.4|^7.0", diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapSessionParameter.php b/src/Symfony/Component/HttpKernel/Attribute/MapSessionParameter.php new file mode 100644 index 0000000000000..af6d2cdc28998 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapSessionParameter.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\Attribute; + +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionParameterValueResolver; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; + +/** + * Can be used to pass a session parameter to a controller argument. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapSessionParameter extends ValueResolver +{ + /** + * @param string|null $name The name of the session parameter; if null, the name of the argument in the controller will be used + * @param class-string|string $resolver The name of the resolver to use + */ + public function __construct( + public ?string $name = null, + string $resolver = SessionParameterValueResolver::class, + ) { + parent::__construct($resolver); + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 6bf1a60ebc6e2..f6362010cceeb 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -8,7 +8,8 @@ CHANGELOG * Support `Uid` in `#[MapQueryParameter]` * Add `ServicesResetterInterface`, implemented by `ServicesResetter` * Allow configuring the logging channel per type of exceptions in ErrorListener - + * Add `#[MapSessionParameter]` to pass a session parameter to a controller argument + 7.2 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/SessionParameterValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/SessionParameterValueResolver.php new file mode 100644 index 0000000000000..3e8106509ff99 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/SessionParameterValueResolver.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapSessionParameter; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; + +final class SessionParameterValueResolver implements ValueResolverInterface +{ + public function resolve(Request $request, ArgumentMetadata $argument): array + { + if (!$attribute = $argument->getAttributesOfType(MapSessionParameter::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) { + return []; + } + + if (!$request->hasSession()) { + return []; + } + + if ((!$type = $argument->getType()) || (!class_exists($type) && !interface_exists($type, false))) { + if ($type && (str_contains($type, '|') || str_contains($type, '&'))) { + if (!$argument->hasDefaultValue() && !$argument->isNullable()) { + throw new \LogicException(\sprintf('#[MapSessionParameter] cannot be used on controller argument "$%s": "%s" is an union or intersection type, you need to make the parameter nullable or provide a default value..', $argument->getName(), $type)); + } + } else { + throw new \LogicException(\sprintf('#[MapSessionParameter] cannot be used on controller argument "$%s": "%s" is not a class or interface name.', $argument->getName(), $type)); + } + } + + if (interface_exists($type, false) && !$argument->hasDefaultValue() && !$argument->isNullable()) { + throw new \LogicException(\sprintf('#[MapSessionParameter] cannot be used on controller argument "$%s": "%s" is an interface, you need to make the parameter nullable or provide a default value.', $argument->getName(), $type)); + } + + $name = $attribute->name ?? $argument->getName(); + if ($request->getSession()->has($name)) { + $value = $request->getSession()->get($name); + if (!$value instanceof $type && !str_contains($type, '|') && !str_contains($type, '&')) { + throw new \LogicException(\sprintf('#[MapSessionParameter] cannot be used to map controller argument "$%s": the session contains a value of type "%s" which is not an instance of "%s".', $argument->getName(), get_debug_type($value), $type)); + } + + return [$value]; + } + + if ($argument->hasDefaultValue()) { + $value = $argument->getDefaultValue(); + } else { + // handle the type SessionInterface|null $param, which doesn't have a default value and can't be instantiated. + $value = class_exists($type, false) ? new $type() : null; + } + + if (\is_object($value)) { + $request->getSession()->set($name, $value); + } + + return [$value]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/SessionParameterValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/SessionParameterValueResolverTest.php new file mode 100644 index 0000000000000..70d5e137ae59b --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/SessionParameterValueResolverTest.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\HttpKernel\Attribute\MapSessionParameter; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionParameterValueResolver; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; + +class SessionParameterValueResolverTest extends TestCase +{ + private ValueResolverInterface $resolver; + + private Request $request; + + protected function setUp(): void + { + $this->resolver = new SessionParameterValueResolver(); + + $session = new Session(new MockArraySessionStorage()); + $this->request = Request::create('/'); + $this->request->setSession($session); + } + + public function testSkipWhenNoAttribute() + { + $metadata = new ArgumentMetadata('browsingContext', 'string', false, true, false); + + $this->assertSame([], $this->resolver->resolve($this->request, $metadata)); + } + + public function testSkipWhenNoSession() + { + $metadata = new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]); + + $this->assertSame([], $this->resolver->resolve(Request::create('/'), $metadata)); + } + + /** + * @dataProvider invalidArgumentTypeProvider + */ + public function testResolvingWithInvalidArgumentType(ArgumentMetadata $metadata, string $exceptionMessage) + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->resolver->resolve($this->request, $metadata); + } + + /** + * @return iterable + */ + public static function invalidArgumentTypeProvider(): iterable + { + yield 'untyped parameter' => [ + new ArgumentMetadata('MySessionObject', null, false, false, false, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "" is not a class or interface name.', + ]; + + yield 'scalar parameter' => [ + new ArgumentMetadata('MySessionObject', 'string', false, false, false, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "string" is not a class or interface name.', + ]; + + yield 'variadic scalar parameter' => [ + new ArgumentMetadata('MySessionObject', 'string', true, false, false, true, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "string" is not a class or interface name.', + ]; + + yield 'interface without default value and not nullable' => [ + new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, false, false, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\SessionParameterInterface" is an interface, you need to make the parameter nullable or provide a default value.', + ]; + + yield 'union type' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'|'.EmptySessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\BasicSessionParameter|Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\EmptySessionParameter" is an union or intersection type, you need to make the parameter nullable or provide a default value.', + ]; + + yield 'intersection type' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'&'.EmptySessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]), + '#[MapSessionParameter] cannot be used on controller argument "$MySessionObject": "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\BasicSessionParameter&Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\EmptySessionParameter" is an union or intersection type, you need to make the parameter nullable or provide a default value.', + ]; + } + + /** + * @dataProvider validDataProvider + */ + public function testResolvingSuccessfully(ArgumentMetadata $metadata, ?string $expectedType) + { + $result = $this->resolver->resolve($this->request, $metadata); + $this->assertCount(1, $result); + + if (null === $expectedType) { + $this->assertNull($result[0]); + } else { + $this->assertInstanceOf($expectedType, $result[0]); + } + } + + /** + * @return iterable + */ + public static function validDataProvider(): iterable + { + yield 'typed parameter with properties' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + yield 'typed parameter without properties' => [ + new ArgumentMetadata('MySessionObject', EmptySessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]), + EmptySessionParameter::class, + ]; + yield 'stdClass parameter' => [ + new ArgumentMetadata('MySessionObject', \stdClass::class, false, false, false, attributes: [new MapSessionParameter()]), + \stdClass::class, + ]; + yield 'variadic parameter' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, true, false, false, attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + yield 'nullable parameter' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, false, false, true, attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + yield 'default to null parameter' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, true, null, true, attributes: [new MapSessionParameter()]), + null, + ]; + yield 'nullable interface without default value' => [ + new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, false, null, true, attributes: [new MapSessionParameter()]), + null, + ]; + yield 'nullable interface defaulting to null' => [ + new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, true, null, true, attributes: [new MapSessionParameter()]), + null, + ]; + yield 'interface with default value' => [ + new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, true, new BasicSessionParameter(), false, attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + + yield 'nullable union type without default value' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'|'.EmptySessionParameter::class, false, true, null, attributes: [new MapSessionParameter()]), + null, + ]; + + yield 'nullable intersection type without default value' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'&'.EmptySessionParameter::class, false, true, null, attributes: [new MapSessionParameter()]), + null, + ]; + + yield 'union type with default value' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'|'.EmptySessionParameter::class, false, true, new BasicSessionParameter(), attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + + yield 'intersection type with default value' => [ + new ArgumentMetadata('MySessionObject', BasicSessionParameter::class.'&'.EmptySessionParameter::class, false, true, new BasicSessionParameter(), attributes: [new MapSessionParameter()]), + BasicSessionParameter::class, + ]; + } + + public function testWithoutNameParameter() + { + $metadata = new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, false, false, attributes: [new MapSessionParameter()]); + $this->resolver->resolve($this->request, $metadata); + $this->assertEquals(['MySessionObject'], array_keys($this->request->getSession()->all())); + } + + /** + * @dataProvider sessionNameProvider + */ + public function testNameParameter(?string $name, string $sessionKey) + { + $metadata = new ArgumentMetadata('MySessionObject', BasicSessionParameter::class, false, false, false, attributes: [ + new MapSessionParameter($name), + ]); + $this->resolver->resolve($this->request, $metadata); + $this->assertEquals([$sessionKey], array_keys($this->request->getSession()->all())); + } + + /** + * @return iterable + */ + public static function sessionNameProvider(): iterable + { + yield 'no value' => [null, 'MySessionObject']; + yield 'same as class' => ['MySessionObject', 'MySessionObject']; + yield 'empty' => ['', '']; + yield 'other' => ['other', 'other']; + } + + public function testResolvingCorrectTypeSuccessfully() + { + $this->request->getSession()->set('MySessionObject', new ExtendingEmptySessionParameter()); + $result = $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', EmptySessionParameter::class, false, false, false, attributes: [new MapSessionParameter()])); + + $this->assertCount(1, $result); + $this->assertInstanceOf(EmptySessionParameter::class, $result[0]); + } + + public function testResolvingCorrectInterfaceSuccessfully() + { + $this->request->getSession()->set('MySessionObject', new BasicSessionParameter()); + $result = $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, false, false, isNullable: true, attributes: [new MapSessionParameter()])); + + $this->assertCount(1, $result); + $this->assertInstanceOf(SessionParameterInterface::class, $result[0]); + } + + public function testResolvingCorrectUnionSuccessfully() + { + $this->request->getSession()->set('MySessionObject', new BasicSessionParameter()); + $result = $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', SessionParameterInterface::class.'|'.EmptySessionParameter::class, false, false, false, isNullable: true, attributes: [new MapSessionParameter()])); + + $this->assertCount(1, $result); + $this->assertInstanceOf(BasicSessionParameter::class, $result[0]); + } + + public function testResolvingCorrectIntersectionSuccessfully() + { + $this->request->getSession()->set('MySessionObject', new BasicSessionParameter()); + $result = $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', SessionParameterInterface::class.'&'.BasicSessionParameter::class, false, false, false, isNullable: true, attributes: [new MapSessionParameter()])); + + $this->assertCount(1, $result); + $this->assertInstanceOf(BasicSessionParameter::class, $result[0]); + } + + public function testResolvingIncorrectTypeFailure() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('#[MapSessionParameter] cannot be used to map controller argument "$MySessionObject": the session contains a value of type "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\BasicSessionParameter" which is not an instance of "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\EmptySessionParameter".'); + + $this->request->getSession()->set('MySessionObject', new BasicSessionParameter()); + $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', EmptySessionParameter::class, false, false, false, attributes: [new MapSessionParameter()])); + } + + public function testResolvingIncorrectInterfaceFailure() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('#[MapSessionParameter] cannot be used to map controller argument "$MySessionObject": the session contains a value of type "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\EmptySessionParameter" which is not an instance of "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\SessionParameterInterface".'); + + $this->request->getSession()->set('MySessionObject', new EmptySessionParameter()); + $this->resolver->resolve($this->request, new ArgumentMetadata('MySessionObject', SessionParameterInterface::class, false, false, false, isNullable: true, attributes: [new MapSessionParameter()])); + } +} + +class BasicSessionParameter implements SessionParameterInterface +{ + public $locale; +} + +class EmptySessionParameter +{ +} + +class ExtendingEmptySessionParameter extends EmptySessionParameter +{ +} + +interface SessionParameterInterface +{ +}