Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 0eb0320

Browse filesBrowse files
feature #49134 [HttpKernel] Add #[MapQueryParameter] to map and validate individual query parameters to controller arguments (ruudk, nicolas-grekas)
This PR was merged into the 6.3 branch. Discussion ---------- [HttpKernel] Add `#[MapQueryParameter]` to map and validate individual query parameters to controller arguments | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? |no | New feature? | yes | Deprecations? |no | Tickets | | License | MIT | Doc PR | We increased the PHPStan level from 8 to 9. This lead to some problems when working with query or request parameters. For example: ```php $firstName = $request->get('firstName'); ``` Because Symfony types `Request::get()` as `mixed` there is no type safety and you have to assert everything manually. We then learned that we shouldn't use `Request::get()` but use the explicit parameter bag like: ```php $request->query->get('firstName'); ``` This `ParameterBag` is used for `request` and `query`. It contains interesting methods like: ```php $request->query->getAlpha('firstName') : string; $request->query->getInt('age') : int; ``` This has the benefit that now the returned value is of a correct type. Why aren't we being explicit by requiring the parameters and their types in the controller action instead? Luckily Symfony has a concept called [ValueResolver](https://symfony.com/doc/current/controller/value_resolver.html). It allows you to do dynamically alter what is injected into a controller action. So in this PR, we introduces a new attribute: `#[MapQueryParameter]` that can be used in controller arguments. It allows you to define which parameters your controller is using and which type they should be. For example: ```php #[Route(path: '/', name: 'admin_dashboard')] public function indexAction( #[MapQueryParameter] array $ids, #[MapQueryParameter] string $firstName, #[MapQueryParameter] bool $required, #[MapQueryParameter] int $age, #[MapQueryParameter] string $category = '', #[MapQueryParameter] ?string $theme = null, ) ``` When requesting `/?ids[]=1&ids[]=2&firstName=Ruud&required=3&age=123` you'll get: ``` $ids = ['1', '2'] $firstName = "Ruud" $required = false $age = 123 $category = '' $theme = null ``` It even supports variadic arguments like this: ```php #[Route(path: '/', name: 'admin_dashboard')] public function indexAction( #[MapQueryParameter] string ...$ids, ) ``` When requesting `/?ids[]=111&ids[]=222` the `$ids` argument will have an array with values ['111','222']. Unit testing the controller now also becomes a bit easier, as you only have to pass the required parameters instead of constructing the `Request` object. It's possible to use `FILTER_VALIDATE_*` consts for more precise type descriptions. For example, this ensures that `$ids` is an array of integers: ```php #[MapQueryParameter(filter: \FILTER_VALIDATE_INT)] array $ids ``` And this declares a string that must match a regexp: ```php #[MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/^\w++$/'])] string $name ``` When a filter fails, a 404 is returned. Commits ------- bd7c669 Register QueryParameterValueResolver as "controller.targeted_value_resolver" 17a8f92 [HttpKernel] Allow injecting query parameters in controllers by typing them with `#[MapQueryParameter]` attribute
2 parents 849831c + bd7c669 commit 0eb0320
Copy full SHA for 0eb0320

File tree

Expand file treeCollapse file tree

6 files changed

+367
-0
lines changed
Filter options
Expand file treeCollapse file tree

6 files changed

+367
-0
lines changed

‎src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class UnusedTagsPass implements CompilerPassInterface
4545
'container.service_subscriber',
4646
'container.stack',
4747
'controller.argument_value_resolver',
48+
'controller.targeted_value_resolver',
4849
'controller.service_arguments',
4950
'controller.targeted_value_resolver',
5051
'data_collector',

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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php
+4Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
1717
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DateTimeValueResolver;
1818
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver;
19+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
1920
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
2021
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
2122
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver;
@@ -91,6 +92,9 @@
9192
->set('argument_resolver.variadic', VariadicValueResolver::class)
9293
->tag('controller.argument_value_resolver', ['priority' => -150, 'name' => VariadicValueResolver::class])
9394

95+
->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class)
96+
->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class])
97+
9498
->set('response_listener', ResponseListener::class)
9599
->args([
96100
param('kernel.charset'),
+38Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
15+
16+
/**
17+
* Can be used to pass a query parameter to a controller argument.
18+
*
19+
* @author Ruud Kamphuis <ruud@ticketswap.com>
20+
*/
21+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
22+
final class MapQueryParameter extends ValueResolver
23+
{
24+
/**
25+
* @see https://php.net/filter.filters.validate for filter, flags and options
26+
*
27+
* @param string|null $name The name of the query parameter. If null, the name of the argument in the controller will be used.
28+
*/
29+
public function __construct(
30+
public ?string $name = null,
31+
public ?int $filter = null,
32+
public int $flags = 0,
33+
public array $options = [],
34+
string $resolver = QueryParameterValueResolver::class,
35+
) {
36+
parent::__construct($resolver);
37+
}
38+
}

‎src/Symfony/Component/HttpKernel/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/CHANGELOG.md
+1Lines 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
---
+100Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
} else {
66+
$options['flags'] |= \FILTER_REQUIRE_SCALAR;
67+
}
68+
69+
$filter = match ($argument->getType()) {
70+
'array' => \FILTER_DEFAULT,
71+
'string' => \FILTER_DEFAULT,
72+
'int' => \FILTER_VALIDATE_INT,
73+
'float' => \FILTER_VALIDATE_FLOAT,
74+
'bool' => \FILTER_VALIDATE_BOOL,
75+
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'))
76+
};
77+
78+
$value = filter_var($value, $attribute->filter ?? $filter, $options);
79+
80+
if (null === $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
81+
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name));
82+
}
83+
84+
if (!\is_array($value)) {
85+
return [$value];
86+
}
87+
88+
$filtered = array_filter($value, static fn ($v) => null !== $v);
89+
90+
if ($argument->isVariadic()) {
91+
$filtered = array_values($filtered);
92+
}
93+
94+
if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
95+
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name));
96+
}
97+
98+
return $argument->isVariadic() ? $filtered : [$filtered];
99+
}
100+
}
+223Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.