-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[HttpKernel] Add #[MapQueryParameter]
to map and validate individual query parameters to controller arguments
#49134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\HttpKernel\Attribute; | ||
|
||
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver; | ||
|
||
/** | ||
* Can be used to pass a query parameter to a controller argument. | ||
* | ||
* @author Ruud Kamphuis <ruud@ticketswap.com> | ||
*/ | ||
#[\Attribute(\Attribute::TARGET_PARAMETER)] | ||
final class MapQueryParameter extends ValueResolver | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
/** | ||
* @see https://php.net/filter.filters.validate for filter, flags and options | ||
* | ||
* @param string|null $name The name of the query parameter. If null, the name of the argument in the controller will be used. | ||
*/ | ||
public function __construct( | ||
public ?string $name = null, | ||
ruudk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public ?int $filter = null, | ||
public int $flags = 0, | ||
public array $options = [], | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
string $resolver = QueryParameterValueResolver::class, | ||
) { | ||
parent::__construct($resolver); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* 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\Attribute\MapQueryParameter; | ||
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; | ||
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; | ||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||
|
||
/** | ||
* @author Ruud Kamphuis <ruud@ticketswap.com> | ||
* @author Nicolas Grekas <p@tchwork.com> | ||
*/ | ||
final class QueryParameterValueResolver implements ValueResolverInterface | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
public function resolve(Request $request, ArgumentMetadata $argument): array | ||
{ | ||
if (!$attribute = $argument->getAttributesOfType(MapQueryParameter::class)[0] ?? null) { | ||
return []; | ||
} | ||
|
||
$name = $attribute->name ?? $argument->getName(); | ||
if (!$request->query->has($name)) { | ||
if ($argument->isNullable() || $argument->hasDefaultValue()) { | ||
return []; | ||
} | ||
|
||
throw new NotFoundHttpException(sprintf('Missing query parameter "%s".', $name)); | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
$value = $request->query->all()[$name]; | ||
nicolas-grekas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (null === $attribute->filter && 'array' === $argument->getType()) { | ||
if (!$argument->isVariadic()) { | ||
return [(array) $value]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We consider |
||
} | ||
|
||
$filtered = array_values(array_filter((array) $value, \is_array(...))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We validate that |
||
|
||
if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For arrays/variadics, I made FILTER_NULL_ON_FAILURE mean to skip invalid values |
||
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); | ||
} | ||
|
||
return $filtered; | ||
} | ||
|
||
$options = [ | ||
'flags' => $attribute->flags | \FILTER_NULL_ON_FAILURE, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. forcing FILTER_NULL_ON_FAILURE here to detect invalid booleans |
||
'options' => $attribute->options, | ||
]; | ||
|
||
if ('array' === $argument->getType() || $argument->isVariadic()) { | ||
$value = (array) $value; | ||
$options['flags'] |= \FILTER_REQUIRE_ARRAY; | ||
} else { | ||
$options['flags'] |= \FILTER_REQUIRE_SCALAR; | ||
} | ||
|
||
$filter = match ($argument->getType()) { | ||
'array' => \FILTER_DEFAULT, | ||
'string' => \FILTER_DEFAULT, | ||
'int' => \FILTER_VALIDATE_INT, | ||
'float' => \FILTER_VALIDATE_FLOAT, | ||
'bool' => \FILTER_VALIDATE_BOOL, | ||
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')) | ||
}; | ||
|
||
$value = filter_var($value, $attribute->filter ?? $filter, $options); | ||
GromNaN marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (null === $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { | ||
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); | ||
} | ||
|
||
if (!\is_array($value)) { | ||
return [$value]; | ||
} | ||
|
||
$filtered = array_filter($value, static fn ($v) => null !== $v); | ||
|
||
if ($argument->isVariadic()) { | ||
$filtered = array_values($filtered); | ||
} | ||
|
||
if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { | ||
throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); | ||
} | ||
|
||
return $argument->isVariadic() ? $filtered : [$filtered]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,223 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* 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\Attribute\MapQueryParameter; | ||
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver; | ||
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; | ||
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; | ||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||
|
||
class QueryParameterValueResolverTest extends TestCase | ||
{ | ||
private ValueResolverInterface $resolver; | ||
|
||
protected function setUp(): void | ||
{ | ||
$this->resolver = new QueryParameterValueResolver(); | ||
} | ||
|
||
/** | ||
* @dataProvider provideTestResolve | ||
*/ | ||
public function testResolve(Request $request, ArgumentMetadata $metadata, array $expected, string $exceptionClass = null, string $exceptionMessage = null) | ||
{ | ||
if ($exceptionMessage) { | ||
self::expectException($exceptionClass); | ||
self::expectExceptionMessage($exceptionMessage); | ||
} | ||
|
||
self::assertSame($expected, $this->resolver->resolve($request, $metadata)); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{ | ||
* Request, | ||
* ArgumentMetadata, | ||
* array<mixed>, | ||
* null|class-string<\Exception>, | ||
* null|string | ||
* }> | ||
*/ | ||
public static function provideTestResolve(): iterable | ||
{ | ||
yield 'parameter found and array' => [ | ||
Request::create('/', 'GET', ['ids' => ['1', '2']]), | ||
new ArgumentMetadata('ids', 'array', false, false, false, attributes: [new MapQueryParameter()]), | ||
[['1', '2']], | ||
null, | ||
]; | ||
yield 'parameter found and array variadic' => [ | ||
Request::create('/', 'GET', ['ids' => [['1', '2'], ['2']]]), | ||
new ArgumentMetadata('ids', 'array', true, false, false, attributes: [new MapQueryParameter()]), | ||
[['1', '2'], ['2']], | ||
null, | ||
]; | ||
yield 'parameter found and string' => [ | ||
Request::create('/', 'GET', ['firstName' => 'John']), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]), | ||
['John'], | ||
null, | ||
]; | ||
yield 'parameter found and string variadic' => [ | ||
Request::create('/', 'GET', ['ids' => ['1', '2']]), | ||
new ArgumentMetadata('ids', 'string', true, false, false, attributes: [new MapQueryParameter()]), | ||
['1', '2'], | ||
null, | ||
]; | ||
yield 'parameter found and string with regexp filter that matches' => [ | ||
Request::create('/', 'GET', ['firstName' => 'John']), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), | ||
['John'], | ||
null, | ||
]; | ||
yield 'parameter found and string with regexp filter that falls back to null on failure' => [ | ||
Request::create('/', 'GET', ['firstName' => 'Fabien']), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), | ||
[null], | ||
null, | ||
]; | ||
yield 'parameter found and string with regexp filter that does not match' => [ | ||
Request::create('/', 'GET', ['firstName' => 'Fabien']), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), | ||
[], | ||
NotFoundHttpException::class, | ||
'Invalid query parameter "firstName".', | ||
]; | ||
yield 'parameter found and string variadic with regexp filter that matches' => [ | ||
Request::create('/', 'GET', ['firstName' => ['John', 'John']]), | ||
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), | ||
['John', 'John'], | ||
null, | ||
]; | ||
yield 'parameter found and string variadic with regexp filter that falls back to null on failure' => [ | ||
Request::create('/', 'GET', ['firstName' => ['John', 'Fabien']]), | ||
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), | ||
['John'], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and we skip invalid items for arrays |
||
null, | ||
]; | ||
yield 'parameter found and string variadic with regexp filter that does not match' => [ | ||
Request::create('/', 'GET', ['firstName' => ['Fabien']]), | ||
new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), | ||
[], | ||
NotFoundHttpException::class, | ||
'Invalid query parameter "firstName".', | ||
]; | ||
yield 'parameter found and integer' => [ | ||
Request::create('/', 'GET', ['age' => 123]), | ||
new ArgumentMetadata('age', 'int', false, false, false, attributes: [new MapQueryParameter()]), | ||
[123], | ||
null, | ||
]; | ||
yield 'parameter found and integer variadic' => [ | ||
Request::create('/', 'GET', ['age' => [123, 222]]), | ||
new ArgumentMetadata('age', 'int', true, false, false, attributes: [new MapQueryParameter()]), | ||
[123, 222], | ||
null, | ||
]; | ||
yield 'parameter found and float' => [ | ||
Request::create('/', 'GET', ['price' => 10.99]), | ||
new ArgumentMetadata('price', 'float', false, false, false, attributes: [new MapQueryParameter()]), | ||
[10.99], | ||
null, | ||
]; | ||
yield 'parameter found and float variadic' => [ | ||
Request::create('/', 'GET', ['price' => [10.99, 5.99]]), | ||
new ArgumentMetadata('price', 'float', true, false, false, attributes: [new MapQueryParameter()]), | ||
[10.99, 5.99], | ||
null, | ||
]; | ||
yield 'parameter found and boolean yes' => [ | ||
Request::create('/', 'GET', ['isVerified' => 'yes']), | ||
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), | ||
[true], | ||
null, | ||
]; | ||
yield 'parameter found and boolean yes variadic' => [ | ||
Request::create('/', 'GET', ['isVerified' => ['yes', 'yes']]), | ||
new ArgumentMetadata('isVerified', 'bool', true, false, false, attributes: [new MapQueryParameter()]), | ||
[true, true], | ||
null, | ||
]; | ||
yield 'parameter found and boolean true' => [ | ||
Request::create('/', 'GET', ['isVerified' => 'true']), | ||
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), | ||
[true], | ||
null, | ||
]; | ||
yield 'parameter found and boolean 1' => [ | ||
Request::create('/', 'GET', ['isVerified' => '1']), | ||
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), | ||
[true], | ||
null, | ||
]; | ||
yield 'parameter found and boolean no' => [ | ||
Request::create('/', 'GET', ['isVerified' => 'no']), | ||
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), | ||
[false], | ||
null, | ||
]; | ||
yield 'parameter found and boolean invalid' => [ | ||
Request::create('/', 'GET', ['isVerified' => 'whatever']), | ||
new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), | ||
[], | ||
NotFoundHttpException::class, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we now properly detect invalid booleans |
||
'Invalid query parameter "isVerified".', | ||
]; | ||
|
||
yield 'parameter not found but nullable' => [ | ||
Request::create('/', 'GET'), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, true, [new MapQueryParameter()]), | ||
[], | ||
null, | ||
]; | ||
|
||
yield 'parameter not found but optional' => [ | ||
Request::create('/', 'GET'), | ||
new ArgumentMetadata('firstName', 'string', false, true, false, attributes: [new MapQueryParameter()]), | ||
[], | ||
null, | ||
]; | ||
|
||
yield 'parameter not found' => [ | ||
Request::create('/', 'GET'), | ||
new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]), | ||
[], | ||
NotFoundHttpException::class, | ||
'Missing query parameter "firstName".', | ||
]; | ||
|
||
yield 'unsupported type' => [ | ||
Request::create('/', 'GET', ['standardClass' => 'test']), | ||
new ArgumentMetadata('standardClass', \stdClass::class, false, false, false, attributes: [new MapQueryParameter()]), | ||
[], | ||
\LogicException::class, | ||
'#[MapQueryParameter] cannot be used on controller argument "$standardClass" of type "stdClass"; one of array, string, int, float or bool should be used.', | ||
]; | ||
yield 'unsupported type variadic' => [ | ||
Request::create('/', 'GET', ['standardClass' => 'test']), | ||
new ArgumentMetadata('standardClass', \stdClass::class, true, false, false, attributes: [new MapQueryParameter()]), | ||
[], | ||
\LogicException::class, | ||
'#[MapQueryParameter] cannot be used on controller argument "...$standardClass" of type "stdClass"; one of array, string, int, float or bool should be used.', | ||
]; | ||
} | ||
|
||
public function testSkipWhenNoAttribute() | ||
{ | ||
$metadata = new ArgumentMetadata('firstName', 'string', false, true, false); | ||
|
||
self::assertSame([], $this->resolver->resolve(Request::create('/'), $metadata)); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.