Skip to content

[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 1 commit
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
Next Next commit
[HttpKernel] Allow injecting query parameters in controllers by typin…
…g them with `#[MapQueryParameter]` attribute
  • Loading branch information
ruudk authored and nicolas-grekas committed Apr 14, 2023
commit 17a8f9288feca324e743953d50d2a6910d0b9dd0
34 changes: 34 additions & 0 deletions src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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;

/**
* 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
{
/**
* @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,
public ?int $filter = null,
public int $flags = 0,
public array $options = [],
) {
}
}
1 change: 1 addition & 0 deletions 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,98 @@
<?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
{
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));
}

$value = $request->query->all()[$name];

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;
}

$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);

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));
}
}