Skip to content

Commit 8466cfe

Browse files
ogizanagifabpot
authored andcommitted
[HttpKernel] Add a controller argument resolver for backed enums
1 parent cef3d5a commit 8466cfe

File tree

5 files changed

+224
-0
lines changed

5 files changed

+224
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

+6
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
use Symfony\Component\HttpKernel\Attribute\AsController;
8080
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
8181
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
82+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
8283
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
8384
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
8485
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
@@ -238,6 +239,11 @@ public function load(array $configs, ContainerBuilder $container)
238239
$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config'));
239240

240241
$loader->load('web.php');
242+
243+
if (\PHP_VERSION_ID < 80100 || !class_exists(BackedEnumValueResolver::class)) {
244+
$container->removeDefinition('argument_resolver.backed_enum_resolver');
245+
}
246+
241247
$loader->load('services.php');
242248
$loader->load('fragment_renderer.php');
243249
$loader->load('error_renderer.php');

src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver;
1515
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
16+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
1617
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver;
1718
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
1819
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver;
@@ -45,6 +46,11 @@
4546
abstract_arg('argument value resolvers'),
4647
])
4748

49+
->set('argument_resolver.backed_enum_resolver', BackedEnumValueResolver::class)
50+
->tag('controller.argument_value_resolver', [
51+
'priority' => 105, // prior to the RequestAttributeValueResolver
52+
])
53+
4854
->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class)
4955
->tag('controller.argument_value_resolver', ['priority' => 100])
5056

src/Symfony/Component/HttpKernel/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.1
5+
---
6+
7+
* Add `BackedEnumValueResolver` to resolve backed enum cases from request attributes in controller arguments
8+
49
6.0
510
---
611

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
16+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
17+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
18+
19+
/**
20+
* Attempt to resolve backed enum cases from request attributes, for a route path parameter,
21+
* leading to a 404 Not Found if the attribute value isn't a valid backing value for the enum type.
22+
*
23+
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
24+
*/
25+
class BackedEnumValueResolver implements ArgumentValueResolverInterface
26+
{
27+
public function supports(Request $request, ArgumentMetadata $argument): bool
28+
{
29+
if (!is_subclass_of($argument->getType(), \BackedEnum::class)) {
30+
return false;
31+
}
32+
33+
if ($argument->isVariadic()) {
34+
// only target route path parameters, which cannot be variadic.
35+
return false;
36+
}
37+
38+
// do not support if no value can be resolved at all
39+
// letting the \Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver be used
40+
// or \Symfony\Component\HttpKernel\Controller\ArgumentResolver fail with a meaningful error.
41+
return $request->attributes->has($argument->getName());
42+
}
43+
44+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
45+
{
46+
$value = $request->attributes->get($argument->getName());
47+
48+
if (null === $value) {
49+
yield null;
50+
51+
return;
52+
}
53+
54+
if (!\is_int($value) && !\is_string($value)) {
55+
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)));
56+
}
57+
58+
/** @var class-string<\BackedEnum> $enumType */
59+
$enumType = $argument->getType();
60+
61+
try {
62+
yield $enumType::from($value);
63+
} catch (\ValueError $error) {
64+
throw new NotFoundHttpException(sprintf('Could not resolve the "%s $%s" controller argument: %s', $argument->getType(), $argument->getName(), $error->getMessage()), $error);
65+
}
66+
}
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
17+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
18+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19+
use Symfony\Component\HttpKernel\Tests\Fixtures\Suit;
20+
21+
/**
22+
* @requires PHP 8.1
23+
*/
24+
class BackedEnumValueResolverTest extends TestCase
25+
{
26+
/**
27+
* @dataProvider provideTestSupportsData
28+
*/
29+
public function testSupports(Request $request, ArgumentMetadata $metadata, bool $expectedSupport)
30+
{
31+
$resolver = new BackedEnumValueResolver();
32+
33+
self::assertSame($expectedSupport, $resolver->supports($request, $metadata));
34+
}
35+
36+
public function provideTestSupportsData(): iterable
37+
{
38+
yield 'unsupported type' => [
39+
self::createRequest(['suit' => 'H']),
40+
self::createArgumentMetadata('suit', \stdClass::class),
41+
false,
42+
];
43+
44+
yield 'supports from attributes' => [
45+
self::createRequest(['suit' => 'H']),
46+
self::createArgumentMetadata('suit', Suit::class),
47+
true,
48+
];
49+
50+
yield 'with null attribute value' => [
51+
self::createRequest(['suit' => null]),
52+
self::createArgumentMetadata('suit', Suit::class),
53+
true,
54+
];
55+
56+
yield 'without matching attribute' => [
57+
self::createRequest(),
58+
self::createArgumentMetadata('suit', Suit::class),
59+
false,
60+
];
61+
62+
yield 'unsupported variadic' => [
63+
self::createRequest(['suit' => ['H', 'S']]),
64+
self::createArgumentMetadata(
65+
'suit',
66+
Suit::class,
67+
variadic: true,
68+
),
69+
false,
70+
];
71+
}
72+
73+
/**
74+
* @dataProvider provideTestResolveData
75+
*/
76+
public function testResolve(Request $request, ArgumentMetadata $metadata, $expected)
77+
{
78+
$resolver = new BackedEnumValueResolver();
79+
/** @var \Generator $results */
80+
$results = $resolver->resolve($request, $metadata);
81+
82+
self::assertSame($expected, iterator_to_array($results));
83+
}
84+
85+
public function provideTestResolveData(): iterable
86+
{
87+
yield 'resolves from attributes' => [
88+
self::createRequest(['suit' => 'H']),
89+
self::createArgumentMetadata('suit', Suit::class),
90+
[Suit::Hearts],
91+
];
92+
93+
yield 'with null attribute value' => [
94+
self::createRequest(['suit' => null]),
95+
self::createArgumentMetadata(
96+
'suit',
97+
Suit::class,
98+
),
99+
[null],
100+
];
101+
}
102+
103+
public function testResolveThrowsNotFoundOnInvalidValue()
104+
{
105+
$resolver = new BackedEnumValueResolver();
106+
$request = self::createRequest(['suit' => 'foo']);
107+
$metadata = self::createArgumentMetadata('suit', Suit::class);
108+
109+
$this->expectException(NotFoundHttpException::class);
110+
$this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: "foo" is not a valid backing value for enum');
111+
112+
/** @var \Generator $results */
113+
$results = $resolver->resolve($request, $metadata);
114+
iterator_to_array($results);
115+
}
116+
117+
public function testResolveThrowsOnUnexpectedType()
118+
{
119+
$resolver = new BackedEnumValueResolver();
120+
$request = self::createRequest(['suit' => false]);
121+
$metadata = self::createArgumentMetadata('suit', Suit::class);
122+
123+
$this->expectException(\LogicException::class);
124+
$this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: expecting an int or string, got bool.');
125+
126+
/** @var \Generator $results */
127+
$results = $resolver->resolve($request, $metadata);
128+
iterator_to_array($results);
129+
}
130+
131+
private static function createRequest(array $attributes = []): Request
132+
{
133+
return new Request([], [], $attributes);
134+
}
135+
136+
private static function createArgumentMetadata(string $name, string $type, bool $variadic = false): ArgumentMetadata
137+
{
138+
return new ArgumentMetadata($name, $type, $variadic, false, null);
139+
}
140+
}

0 commit comments

Comments
 (0)