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 new file mode 100644 index 0000000000000..f83e331e4118f --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php @@ -0,0 +1,38 @@ + + * + * 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 + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class MapQueryParameter extends ValueResolver +{ + /** + * @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 = [], + string $resolver = QueryParameterValueResolver::class, + ) { + parent::__construct($resolver); + } +} 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..f2e4bee812d79 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php @@ -0,0 +1,100 @@ + + * + * 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; + } 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); + + 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)); + } +} 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