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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy