From 8466cfe19c250577c25c959ce17e42431640158b Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Tue, 28 Dec 2021 17:00:59 +0100 Subject: [PATCH] [HttpKernel] Add a controller argument resolver for backed enums --- .../FrameworkExtension.php | 6 + .../FrameworkBundle/Resources/config/web.php | 6 + src/Symfony/Component/HttpKernel/CHANGELOG.md | 5 + .../BackedEnumValueResolver.php | 67 +++++++++ .../BackedEnumValueResolverTest.php | 140 ++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 211ed9077839d..02b4e24666449 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -79,6 +79,7 @@ use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -238,6 +239,11 @@ public function load(array $configs, ContainerBuilder $container) $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); $loader->load('web.php'); + + if (\PHP_VERSION_ID < 80100 || !class_exists(BackedEnumValueResolver::class)) { + $container->removeDefinition('argument_resolver.backed_enum_resolver'); + } + $loader->load('services.php'); $loader->load('fragment_renderer.php'); $loader->load('error_renderer.php'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 53613d3b5020c..a7d91bfd4a69d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; @@ -45,6 +46,11 @@ abstract_arg('argument value resolvers'), ]) + ->set('argument_resolver.backed_enum_resolver', BackedEnumValueResolver::class) + ->tag('controller.argument_value_resolver', [ + 'priority' => 105, // prior to the RequestAttributeValueResolver + ]) + ->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class) ->tag('controller.argument_value_resolver', ['priority' => 100]) diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 945557d762f5c..a60b754c6d1ed 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.1 +--- + + * Add `BackedEnumValueResolver` to resolve backed enum cases from request attributes in controller arguments + 6.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php new file mode 100644 index 0000000000000..054354963b313 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php @@ -0,0 +1,67 @@ + + * + * 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\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Attempt to resolve backed enum cases from request attributes, for a route path parameter, + * leading to a 404 Not Found if the attribute value isn't a valid backing value for the enum type. + * + * @author Maxime Steinhausser + */ +class BackedEnumValueResolver implements ArgumentValueResolverInterface +{ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if (!is_subclass_of($argument->getType(), \BackedEnum::class)) { + return false; + } + + if ($argument->isVariadic()) { + // only target route path parameters, which cannot be variadic. + return false; + } + + // do not support if no value can be resolved at all + // letting the \Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver be used + // or \Symfony\Component\HttpKernel\Controller\ArgumentResolver fail with a meaningful error. + return $request->attributes->has($argument->getName()); + } + + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $value = $request->attributes->get($argument->getName()); + + if (null === $value) { + yield null; + + return; + } + + if (!\is_int($value) && !\is_string($value)) { + throw new \LogicException(sprintf('Could not resolve the "%s $%s" controller argument: expecting an int or string, got %s.', $argument->getType(), $argument->getName(), get_debug_type($value))); + } + + /** @var class-string<\BackedEnum> $enumType */ + $enumType = $argument->getType(); + + try { + yield $enumType::from($value); + } catch (\ValueError $error) { + throw new NotFoundHttpException(sprintf('Could not resolve the "%s $%s" controller argument: %s', $argument->getType(), $argument->getName(), $error->getMessage()), $error); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php new file mode 100644 index 0000000000000..900d4b2db24dd --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php @@ -0,0 +1,140 @@ + + * + * 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\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Tests\Fixtures\Suit; + +/** + * @requires PHP 8.1 + */ +class BackedEnumValueResolverTest extends TestCase +{ + /** + * @dataProvider provideTestSupportsData + */ + public function testSupports(Request $request, ArgumentMetadata $metadata, bool $expectedSupport) + { + $resolver = new BackedEnumValueResolver(); + + self::assertSame($expectedSupport, $resolver->supports($request, $metadata)); + } + + public function provideTestSupportsData(): iterable + { + yield 'unsupported type' => [ + self::createRequest(['suit' => 'H']), + self::createArgumentMetadata('suit', \stdClass::class), + false, + ]; + + yield 'supports from attributes' => [ + self::createRequest(['suit' => 'H']), + self::createArgumentMetadata('suit', Suit::class), + true, + ]; + + yield 'with null attribute value' => [ + self::createRequest(['suit' => null]), + self::createArgumentMetadata('suit', Suit::class), + true, + ]; + + yield 'without matching attribute' => [ + self::createRequest(), + self::createArgumentMetadata('suit', Suit::class), + false, + ]; + + yield 'unsupported variadic' => [ + self::createRequest(['suit' => ['H', 'S']]), + self::createArgumentMetadata( + 'suit', + Suit::class, + variadic: true, + ), + false, + ]; + } + + /** + * @dataProvider provideTestResolveData + */ + public function testResolve(Request $request, ArgumentMetadata $metadata, $expected) + { + $resolver = new BackedEnumValueResolver(); + /** @var \Generator $results */ + $results = $resolver->resolve($request, $metadata); + + self::assertSame($expected, iterator_to_array($results)); + } + + public function provideTestResolveData(): iterable + { + yield 'resolves from attributes' => [ + self::createRequest(['suit' => 'H']), + self::createArgumentMetadata('suit', Suit::class), + [Suit::Hearts], + ]; + + yield 'with null attribute value' => [ + self::createRequest(['suit' => null]), + self::createArgumentMetadata( + 'suit', + Suit::class, + ), + [null], + ]; + } + + public function testResolveThrowsNotFoundOnInvalidValue() + { + $resolver = new BackedEnumValueResolver(); + $request = self::createRequest(['suit' => 'foo']); + $metadata = self::createArgumentMetadata('suit', Suit::class); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: "foo" is not a valid backing value for enum'); + + /** @var \Generator $results */ + $results = $resolver->resolve($request, $metadata); + iterator_to_array($results); + } + + public function testResolveThrowsOnUnexpectedType() + { + $resolver = new BackedEnumValueResolver(); + $request = self::createRequest(['suit' => false]); + $metadata = self::createArgumentMetadata('suit', Suit::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: expecting an int or string, got bool.'); + + /** @var \Generator $results */ + $results = $resolver->resolve($request, $metadata); + iterator_to_array($results); + } + + private static function createRequest(array $attributes = []): Request + { + return new Request([], [], $attributes); + } + + private static function createArgumentMetadata(string $name, string $type, bool $variadic = false): ArgumentMetadata + { + return new ArgumentMetadata($name, $type, $variadic, false, null); + } +}