From 17a8f9288feca324e743953d50d2a6910d0b9dd0 Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Fri, 27 Jan 2023 16:33:40 +0100 Subject: [PATCH 1/2] [HttpKernel] Allow injecting query parameters in controllers by typing them with `#[MapQueryParameter]` attribute --- .../Attribute/MapQueryParameter.php | 34 +++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../QueryParameterValueResolver.php | 98 ++++++++ .../QueryParameterValueResolverTest.php | 223 ++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php create mode 100644 src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php new file mode 100644 index 0000000000000..63ea1dd7266a9 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php @@ -0,0 +1,34 @@ + + * + * 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 + */ +#[\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 = [], + ) { + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 22b0a0d52bd1c..838a62cefea95 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -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 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php new file mode 100644 index 0000000000000..b5521b8859e0e --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php @@ -0,0 +1,98 @@ + + * + * 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 + * @author Nicolas Grekas + */ +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]; + } + + $filtered = array_values(array_filter((array) $value, \is_array(...))); + + if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { + throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); + } + + return $filtered; + } + + $options = [ + 'flags' => $attribute->flags | \FILTER_NULL_ON_FAILURE, + '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]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php new file mode 100644 index 0000000000000..539aaac78ee65 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php @@ -0,0 +1,223 @@ + + * + * 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, + * 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'], + 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, + '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)); + } +} From bd7c669c89f43dcea42606e542b184847fabdcaf Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 23 Feb 2023 15:34:23 +0100 Subject: [PATCH 2/2] Register QueryParameterValueResolver as "controller.targeted_value_resolver" --- .../DependencyInjection/Compiler/UnusedTagsPass.php | 1 + src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php | 4 ++++ .../Component/HttpKernel/Attribute/MapQueryParameter.php | 6 +++++- .../ArgumentResolver/QueryParameterValueResolver.php | 2 ++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 9da5b91bb3bb0..11b83394c702e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -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', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 1a41a60fe1ddd..db904f871c535 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -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; @@ -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'), diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php index 63ea1dd7266a9..f83e331e4118f 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php @@ -11,13 +11,15 @@ 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 */ #[\Attribute(\Attribute::TARGET_PARAMETER)] -final class MapQueryParameter +final class MapQueryParameter extends ValueResolver { /** * @see https://php.net/filter.filters.validate for filter, flags and options @@ -29,6 +31,8 @@ public function __construct( public ?int $filter = null, public int $flags = 0, public array $options = [], + string $resolver = QueryParameterValueResolver::class, ) { + parent::__construct($resolver); } } diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php index b5521b8859e0e..f2e4bee812d79 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php @@ -62,6 +62,8 @@ public function resolve(Request $request, ArgumentMetadata $argument): array if ('array' === $argument->getType() || $argument->isVariadic()) { $value = (array) $value; $options['flags'] |= \FILTER_REQUIRE_ARRAY; + } else { + $options['flags'] |= \FILTER_REQUIRE_SCALAR; } $filter = match ($argument->getType()) {