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

[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

Merged
merged 2 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class UnusedTagsPass implements CompilerPassInterface
'container.service_subscriber',
'container.stack',
'controller.argument_value_resolver',
'controller.targeted_value_resolver',
'controller.service_arguments',
'controller.targeted_value_resolver',
'data_collector',
Expand Down
4 changes: 4 additions & 0 deletions 4 src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DateTimeValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver;
Expand Down Expand Up @@ -90,6 +91,9 @@
->set('argument_resolver.variadic', VariadicValueResolver::class)
->tag('controller.argument_value_resolver', ['priority' => -150, 'name' => VariadicValueResolver::class])

->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class)
->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class])

->set('response_listener', ResponseListener::class)
->args([
param('kernel.charset'),
Expand Down
38 changes: 38 additions & 0 deletions 38 src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php
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.
nicolas-grekas marked this conversation as resolved.
Show resolved Hide resolved
*
* @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);
}
}
1 change: 1 addition & 0 deletions 1 src/Symfony/Component/HttpKernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CHANGELOG
* Introduce targeted value resolvers with `#[ValueResolver]` and `#[AsTargetedValueResolver]`
* Add `#[MapRequestPayload]` to map and validate request payload from `Request::getContent()` or `Request::$request->all()` to typed objects
* Add `#[MapQueryString]` to map and validate request query string from `Request::$query->all()` to typed objects
* Add `#[MapQueryParameter]` to map and validate individual query parameters to controller arguments

6.2
---
Expand Down
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];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We consider foo=abc and foo[]=abc to be the same for arrays. This is consistent with InputBag.

}

$filtered = array_values(array_filter((array) $value, \is_array(...)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We validate that array ...$foo is given only arrays


if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) {
Copy link
Member

Choose a reason for hiding this comment

The 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,
Copy link
Member

Choose a reason for hiding this comment

The 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'],
Copy link
Member

Choose a reason for hiding this comment

The 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,
Copy link
Member

Choose a reason for hiding this comment

The 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));
}
}
Morty Proxy This is a proxified and sanitized view of the page, visit original site.