Skip to content

Commit 17a8f92

Browse files
ruudknicolas-grekas
authored andcommitted
[HttpKernel] Allow injecting query parameters in controllers by typing them with #[MapQueryParameter] attribute
1 parent 58f0915 commit 17a8f92

File tree

4 files changed

+356
-0
lines changed

4 files changed

+356
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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\Attribute;
13+
14+
/**
15+
* Can be used to pass a query parameter to a controller argument.
16+
*
17+
* @author Ruud Kamphuis <ruud@ticketswap.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
20+
final class MapQueryParameter
21+
{
22+
/**
23+
* @see https://php.net/filter.filters.validate for filter, flags and options
24+
*
25+
* @param string|null $name The name of the query parameter. If null, the name of the argument in the controller will be used.
26+
*/
27+
public function __construct(
28+
public ?string $name = null,
29+
public ?int $filter = null,
30+
public int $flags = 0,
31+
public array $options = [],
32+
) {
33+
}
34+
}

src/Symfony/Component/HttpKernel/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CHANGELOG
1313
* Introduce targeted value resolvers with `#[ValueResolver]` and `#[AsTargetedValueResolver]`
1414
* Add `#[MapRequestPayload]` to map and validate request payload from `Request::getContent()` or `Request::$request->all()` to typed objects
1515
* Add `#[MapQueryString]` to map and validate request query string from `Request::$query->all()` to typed objects
16+
* Add `#[MapQueryParameter]` to map and validate individual query parameters to controller arguments
1617

1718
6.2
1819
---
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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\Attribute\MapQueryParameter;
16+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
17+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
18+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19+
20+
/**
21+
* @author Ruud Kamphuis <ruud@ticketswap.com>
22+
* @author Nicolas Grekas <p@tchwork.com>
23+
*/
24+
final class QueryParameterValueResolver implements ValueResolverInterface
25+
{
26+
public function resolve(Request $request, ArgumentMetadata $argument): array
27+
{
28+
if (!$attribute = $argument->getAttributesOfType(MapQueryParameter::class)[0] ?? null) {
29+
return [];
30+
}
31+
32+
$name = $attribute->name ?? $argument->getName();
33+
if (!$request->query->has($name)) {
34+
if ($argument->isNullable() || $argument->hasDefaultValue()) {
35+
return [];
36+
}
37+
38+
throw new NotFoundHttpException(sprintf('Missing query parameter "%s".', $name));
39+
}
40+
41+
$value = $request->query->all()[$name];
42+
43+
if (null === $attribute->filter && 'array' === $argument->getType()) {
44+
if (!$argument->isVariadic()) {
45+
return [(array) $value];
46+
}
47+
48+
$filtered = array_values(array_filter((array) $value, \is_array(...)));
49+
50+
if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
51+
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name));
52+
}
53+
54+
return $filtered;
55+
}
56+
57+
$options = [
58+
'flags' => $attribute->flags | \FILTER_NULL_ON_FAILURE,
59+
'options' => $attribute->options,
60+
];
61+
62+
if ('array' === $argument->getType() || $argument->isVariadic()) {
63+
$value = (array) $value;
64+
$options['flags'] |= \FILTER_REQUIRE_ARRAY;
65+
}
66+
67+
$filter = match ($argument->getType()) {
68+
'array' => \FILTER_DEFAULT,
69+
'string' => \FILTER_DEFAULT,
70+
'int' => \FILTER_VALIDATE_INT,
71+
'float' => \FILTER_VALIDATE_FLOAT,
72+
'bool' => \FILTER_VALIDATE_BOOL,
73+
default => throw new \LogicException(sprintf('#[MapQueryParameter] cannot be used on controller argument "%s$%s" of type "%s"; one of array, string, int, float or bool should be used.', $argument->isVariadic() ? '...' : '', $argument->getName(), $argument->getType() ?? 'mixed'))
74+
};
75+
76+
$value = filter_var($value, $attribute->filter ?? $filter, $options);
77+
78+
if (null === $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
79+
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name));
80+
}
81+
82+
if (!\is_array($value)) {
83+
return [$value];
84+
}
85+
86+
$filtered = array_filter($value, static fn ($v) => null !== $v);
87+
88+
if ($argument->isVariadic()) {
89+
$filtered = array_values($filtered);
90+
}
91+
92+
if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
93+
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name));
94+
}
95+
96+
return $argument->isVariadic() ? $filtered : [$filtered];
97+
}
98+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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\Attribute\MapQueryParameter;
17+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
18+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
19+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
20+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
21+
22+
class QueryParameterValueResolverTest extends TestCase
23+
{
24+
private ValueResolverInterface $resolver;
25+
26+
protected function setUp(): void
27+
{
28+
$this->resolver = new QueryParameterValueResolver();
29+
}
30+
31+
/**
32+
* @dataProvider provideTestResolve
33+
*/
34+
public function testResolve(Request $request, ArgumentMetadata $metadata, array $expected, string $exceptionClass = null, string $exceptionMessage = null)
35+
{
36+
if ($exceptionMessage) {
37+
self::expectException($exceptionClass);
38+
self::expectExceptionMessage($exceptionMessage);
39+
}
40+
41+
self::assertSame($expected, $this->resolver->resolve($request, $metadata));
42+
}
43+
44+
/**
45+
* @return iterable<string, array{
46+
* Request,
47+
* ArgumentMetadata,
48+
* array<mixed>,
49+
* null|class-string<\Exception>,
50+
* null|string
51+
* }>
52+
*/
53+
public static function provideTestResolve(): iterable
54+
{
55+
yield 'parameter found and array' => [
56+
Request::create('/', 'GET', ['ids' => ['1', '2']]),
57+
new ArgumentMetadata('ids', 'array', false, false, false, attributes: [new MapQueryParameter()]),
58+
[['1', '2']],
59+
null,
60+
];
61+
yield 'parameter found and array variadic' => [
62+
Request::create('/', 'GET', ['ids' => [['1', '2'], ['2']]]),
63+
new ArgumentMetadata('ids', 'array', true, false, false, attributes: [new MapQueryParameter()]),
64+
[['1', '2'], ['2']],
65+
null,
66+
];
67+
yield 'parameter found and string' => [
68+
Request::create('/', 'GET', ['firstName' => 'John']),
69+
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]),
70+
['John'],
71+
null,
72+
];
73+
yield 'parameter found and string variadic' => [
74+
Request::create('/', 'GET', ['ids' => ['1', '2']]),
75+
new ArgumentMetadata('ids', 'string', true, false, false, attributes: [new MapQueryParameter()]),
76+
['1', '2'],
77+
null,
78+
];
79+
yield 'parameter found and string with regexp filter that matches' => [
80+
Request::create('/', 'GET', ['firstName' => 'John']),
81+
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]),
82+
['John'],
83+
null,
84+
];
85+
yield 'parameter found and string with regexp filter that falls back to null on failure' => [
86+
Request::create('/', 'GET', ['firstName' => 'Fabien']),
87+
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]),
88+
[null],
89+
null,
90+
];
91+
yield 'parameter found and string with regexp filter that does not match' => [
92+
Request::create('/', 'GET', ['firstName' => 'Fabien']),
93+
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]),
94+
[],
95+
NotFoundHttpException::class,
96+
'Invalid query parameter "firstName".',
97+
];
98+
yield 'parameter found and string variadic with regexp filter that matches' => [
99+
Request::create('/', 'GET', ['firstName' => ['John', 'John']]),
100+
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]),
101+
['John', 'John'],
102+
null,
103+
];
104+
yield 'parameter found and string variadic with regexp filter that falls back to null on failure' => [
105+
Request::create('/', 'GET', ['firstName' => ['John', 'Fabien']]),
106+
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]),
107+
['John'],
108+
null,
109+
];
110+
yield 'parameter found and string variadic with regexp filter that does not match' => [
111+
Request::create('/', 'GET', ['firstName' => ['Fabien']]),
112+
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]),
113+
[],
114+
NotFoundHttpException::class,
115+
'Invalid query parameter "firstName".',
116+
];
117+
yield 'parameter found and integer' => [
118+
Request::create('/', 'GET', ['age' => 123]),
119+
new ArgumentMetadata('age', 'int', false, false, false, attributes: [new MapQueryParameter()]),
120+
[123],
121+
null,
122+
];
123+
yield 'parameter found and integer variadic' => [
124+
Request::create('/', 'GET', ['age' => [123, 222]]),
125+
new ArgumentMetadata('age', 'int', true, false, false, attributes: [new MapQueryParameter()]),
126+
[123, 222],
127+
null,
128+
];
129+
yield 'parameter found and float' => [
130+
Request::create('/', 'GET', ['price' => 10.99]),
131+
new ArgumentMetadata('price', 'float', false, false, false, attributes: [new MapQueryParameter()]),
132+
[10.99],
133+
null,
134+
];
135+
yield 'parameter found and float variadic' => [
136+
Request::create('/', 'GET', ['price' => [10.99, 5.99]]),
137+
new ArgumentMetadata('price', 'float', true, false, false, attributes: [new MapQueryParameter()]),
138+
[10.99, 5.99],
139+
null,
140+
];
141+
yield 'parameter found and boolean yes' => [
142+
Request::create('/', 'GET', ['isVerified' => 'yes']),
143+
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]),
144+
[true],
145+
null,
146+
];
147+
yield 'parameter found and boolean yes variadic' => [
148+
Request::create('/', 'GET', ['isVerified' => ['yes', 'yes']]),
149+
new ArgumentMetadata('isVerified', 'bool', true, false, false, attributes: [new MapQueryParameter()]),
150+
[true, true],
151+
null,
152+
];
153+
yield 'parameter found and boolean true' => [
154+
Request::create('/', 'GET', ['isVerified' => 'true']),
155+
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]),
156+
[true],
157+
null,
158+
];
159+
yield 'parameter found and boolean 1' => [
160+
Request::create('/', 'GET', ['isVerified' => '1']),
161+
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]),
162+
[true],
163+
null,
164+
];
165+
yield 'parameter found and boolean no' => [
166+
Request::create('/', 'GET', ['isVerified' => 'no']),
167+
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]),
168+
[false],
169+
null,
170+
];
171+
yield 'parameter found and boolean invalid' => [
172+
Request::create('/', 'GET', ['isVerified' => 'whatever']),
173+
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]),
174+
[],
175+
NotFoundHttpException::class,
176+
'Invalid query parameter "isVerified".',
177+
];
178+
179+
yield 'parameter not found but nullable' => [
180+
Request::create('/', 'GET'),
181+
new ArgumentMetadata('firstName', 'string', false, false, false, true, [new MapQueryParameter()]),
182+
[],
183+
null,
184+
];
185+
186+
yield 'parameter not found but optional' => [
187+
Request::create('/', 'GET'),
188+
new ArgumentMetadata('firstName', 'string', false, true, false, attributes: [new MapQueryParameter()]),
189+
[],
190+
null,
191+
];
192+
193+
yield 'parameter not found' => [
194+
Request::create('/', 'GET'),
195+
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]),
196+
[],
197+
NotFoundHttpException::class,
198+
'Missing query parameter "firstName".',
199+
];
200+
201+
yield 'unsupported type' => [
202+
Request::create('/', 'GET', ['standardClass' => 'test']),
203+
new ArgumentMetadata('standardClass', \stdClass::class, false, false, false, attributes: [new MapQueryParameter()]),
204+
[],
205+
\LogicException::class,
206+
'#[MapQueryParameter] cannot be used on controller argument "$standardClass" of type "stdClass"; one of array, string, int, float or bool should be used.',
207+
];
208+
yield 'unsupported type variadic' => [
209+
Request::create('/', 'GET', ['standardClass' => 'test']),
210+
new ArgumentMetadata('standardClass', \stdClass::class, true, false, false, attributes: [new MapQueryParameter()]),
211+
[],
212+
\LogicException::class,
213+
'#[MapQueryParameter] cannot be used on controller argument "...$standardClass" of type "stdClass"; one of array, string, int, float or bool should be used.',
214+
];
215+
}
216+
217+
public function testSkipWhenNoAttribute()
218+
{
219+
$metadata = new ArgumentMetadata('firstName', 'string', false, true, false);
220+
221+
self::assertSame([], $this->resolver->resolve(Request::create('/'), $metadata));
222+
}
223+
}

0 commit comments

Comments
 (0)