diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php index 7ddf3e72186d6..7b22f01b39352 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php @@ -18,7 +18,9 @@ use Doctrine\Persistence\ObjectManager; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -31,6 +33,8 @@ */ final class EntityValueResolver implements ValueResolverInterface { + use RequestParameterValueResolverTrait; + public function __construct( private ManagerRegistry $registry, private ?ExpressionLanguage $expressionLanguage = null, @@ -38,9 +42,15 @@ public function __construct( ) { } - public function resolve(Request $request, ArgumentMetadata $argument): array + protected function supports(ArgumentMetadata $argument): bool + { + return $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF) + || ($argument->getType() && $this->getManager($this->defaults->objectManager, $argument->getType())); + } + + protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array { - if (\is_object($request->attributes->get($argument->getName()))) { + if (\is_object($valueBag->get($argument->getName()))) { return []; } @@ -56,13 +66,13 @@ public function resolve(Request $request, ArgumentMetadata $argument): array $message = ''; if (null !== $options->expr) { - if (null === $object = $this->findViaExpression($manager, $request, $options)) { + if (null === $object = $this->findViaExpression($manager, $request, $options, $valueBag)) { $message = \sprintf(' The expression "%s" returned null.', $options->expr); } // find by identifier? - } elseif (false === $object = $this->find($manager, $request, $options, $argument)) { + } elseif (false === $object = $this->find($manager, $request, $options, $argument, $valueBag)) { // find by criteria - if (!$criteria = $this->getCriteria($request, $options, $manager, $argument)) { + if (!$criteria = $this->getCriteria($options, $manager, $argument, $valueBag)) { return []; } try { @@ -94,13 +104,13 @@ private function getManager(?string $name, string $class): ?ObjectManager return $manager->getMetadataFactory()->isTransient($class) ? null : $manager; } - private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null + private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument, ParameterBag $valueBag): false|object|null { if ($options->mapping || $options->exclude) { return false; } - $id = $this->getIdentifier($request, $options, $argument); + $id = $this->getIdentifier($options, $argument, $valueBag); if (false === $id || null === $id) { return $id; } @@ -122,7 +132,7 @@ private function find(ObjectManager $manager, Request $request, MapEntity $optio } } - private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed + private function getIdentifier(MapEntity $options, ArgumentMetadata $argument, ParameterBag $valueBag): mixed { if (\is_array($options->id)) { $id = []; @@ -132,24 +142,24 @@ private function getIdentifier(Request $request, MapEntity $options, ArgumentMet $field = \sprintf($field, $argument->getName()); } - $id[$field] = $request->attributes->get($field); + $id[$field] = $valueBag->get($field); } return $id; } if ($options->id) { - return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null); + return $valueBag->get($options->id) ?? ($options->stripNull ? false : null); } $name = $argument->getName(); - if ($request->attributes->has($name)) { - if (\is_array($id = $request->attributes->get($name))) { + if ($valueBag->has($name)) { + if (\is_array($id = $valueBag->get($name))) { return false; } - foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) { + foreach ($valueBag->get('_route_mapping') ?? [] as $parameter => $attribute) { if ($name === $attribute) { $options->mapping = [$name => $parameter]; @@ -160,16 +170,16 @@ private function getIdentifier(Request $request, MapEntity $options, ArgumentMet return $id ?? ($options->stripNull ? false : null); } - if ($request->attributes->has('id')) { - return $request->attributes->get('id') ?? ($options->stripNull ? false : null); + if ($valueBag->has('id')) { + return $valueBag->get('id') ?? ($options->stripNull ? false : null); } return false; } - private function getCriteria(Request $request, MapEntity $options, ObjectManager $manager, ArgumentMetadata $argument): array + private function getCriteria(MapEntity $options, ObjectManager $manager, ArgumentMetadata $argument, ParameterBag $valueBag): array { - if (!($mapping = $options->mapping) && \is_array($criteria = $request->attributes->get($argument->getName()))) { + if (!($mapping = $options->mapping) && \is_array($criteria = $valueBag->get($argument->getName()))) { foreach ($options->exclude as $exclude) { unset($criteria[$exclude]); } @@ -181,7 +191,7 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager return $criteria; } elseif (null === $mapping) { trigger_deprecation('symfony/doctrine-bridge', '7.1', 'Relying on auto-mapping for Doctrine entities is deprecated for argument $%s of "%s": declare the identifier using either the #[MapEntity] attribute or mapped route parameters.', $argument->getName(), method_exists($argument, 'getControllerName') ? $argument->getControllerName() : 'n/a'); - $mapping = $request->attributes->keys(); + $mapping = $valueBag->keys(); } if ($mapping && array_is_list($mapping)) { @@ -204,7 +214,7 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager continue; } - $criteria[$field] = $request->attributes->get($attribute); + $criteria[$field] = $valueBag->get($attribute); } if ($options->stripNull) { @@ -214,14 +224,14 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager return $criteria; } - private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options): object|iterable|null + private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options, ParameterBag $valueBag): object|iterable|null { if (!$this->expressionLanguage) { throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__)); } $repository = $manager->getRepository($options->class); - $variables = array_merge($request->attributes->all(), [ + $variables = array_merge($valueBag->all(), [ 'repository' => $repository, 'request' => $request, ]); diff --git a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php index 898631bac842c..98bef568ed3c1 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php @@ -23,6 +23,7 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\ExpressionLanguage\SyntaxError; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\FromQuery; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -297,6 +298,38 @@ public function testResolveWithRouteMapping() $this->assertSame([$article], $resolver->resolve($request, $argument2)); } + public function testResolveFromValueBag() + { + $manager = $this->createMock(ObjectManager::class); + $registry = $this->createRegistry($manager); + $resolver = new EntityValueResolver($registry); + + $request = new Request(); + $request->query->set('conference_place', 'vienna'); + $request->query->set('conference_year', '2024'); + + $argument1 = $this->createArgument('Conference', new MapEntity('Conference', mapping: ['conference_place' => 'place', 'conference_year' => 'year']), 'conference', false, [new FromQuery('*')]); + + $manager->expects($this->never()) + ->method('getClassMetadata'); + + $conference = new \stdClass(); + + $repository = $this->createMock(ObjectRepository::class); + $repository->expects($this->any()) + ->method('findOneBy') + ->willReturnCallback(static fn ($v) => match ($v) { + ['place' => 'vienna', 'year' => '2024'] => $conference, + default => dd($v), + }); + + $manager->expects($this->any()) + ->method('getRepository') + ->willReturn($repository); + + $this->assertSame([$conference], $resolver->resolve($request, $argument1)); + } + public function testExceptionWithExpressionIfNoLanguageAvailable() { $manager = $this->createMock(ObjectManager::class); @@ -385,6 +418,58 @@ public function testExpressionMapsToArgument() $this->assertSame([$object], $resolver->resolve($request, $argument)); } + public static function provideExpressionMapsToArgumentFromQueryExpressions(): array + { + return [ + 'with args in global scope' => ['repository.findByPlaceAndYear(place, year)'], + 'with args in argument value scope' => ['repository.findByPlaceAndYear(conference.place, conference.year)'], + ]; + } + + /** @dataProvider provideExpressionMapsToArgumentFromQueryExpressions */ + public function testExpressionMapsToArgumentFromQuery(string $expr) + { + $manager = $this->createMock(ObjectManager::class); + $registry = $this->createRegistry($manager); + $language = $this->createMock(ExpressionLanguage::class); + $resolver = new EntityValueResolver($registry, $language); + + $request = new Request(); + $request->query->set('place', 'vienna'); + $request->query->set('year', '2024'); + + $argument = $this->createArgument( + 'stdClass', + new MapEntity(expr: $expr), + 'conference', + false, + [new FromQuery('*')] + ); + + $repository = $this->createMock(ObjectRepository::class); + // find should not be attempted on this repository as a fallback + $repository->expects($this->never()) + ->method('find'); + + $manager->expects($this->once()) + ->method('getRepository') + ->with(\stdClass::class) + ->willReturn($repository); + + $language->expects($this->once()) + ->method('evaluate') + ->with($expr, [ + 'repository' => $repository, + 'request' => $request, + 'place' => 'vienna', + 'year' => '2024', + 'conference' => ['place' => 'vienna', 'year' => '2024'], + ]) + ->willReturn($object = new \stdClass()); + + $this->assertSame([$object], $resolver->resolve($request, $argument)); + } + public function testExpressionMapsToIterableArgument() { $manager = $this->createMock(ObjectManager::class); @@ -474,9 +559,13 @@ public function testAlreadyResolved() $this->assertSame([], $resolver->resolve($request, $argument)); } - private function createArgument(?string $class = null, ?MapEntity $entity = null, string $name = 'arg', bool $isNullable = false): ArgumentMetadata + private function createArgument(?string $class = null, ?MapEntity $entity = null, string $name = 'arg', bool $isNullable = false, array $attributes = []): ArgumentMetadata { - return new ArgumentMetadata($name, $class ?? \stdClass::class, false, false, null, $isNullable, $entity ? [$entity] : []); + if ($entity) { + $attributes[] = $entity; + } + + return new ArgumentMetadata($name, $class ?? \stdClass::class, false, false, null, $isNullable, $attributes); } private function createRegistry(?ObjectManager $manager = null): ManagerRegistry&MockObject diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index 1ca2abc4b13c4..94a089685b853 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -31,7 +31,7 @@ "symfony/doctrine-messenger": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", "symfony/form": "^6.4.6|^7.0.6", - "symfony/http-kernel": "^6.4|^7.0", + "symfony/http-kernel": "^7.3", "symfony/lock": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", "symfony/property-access": "^6.4|^7.0", @@ -58,7 +58,7 @@ "symfony/dependency-injection": "<6.4", "symfony/form": "<6.4.6|>=7,<7.0.6", "symfony/http-foundation": "<6.4", - "symfony/http-kernel": "<6.4", + "symfony/http-kernel": "<7.3", "symfony/lock": "<6.4", "symfony/messenger": "<6.4", "symfony/property-info": "<6.4", diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromBody.php b/src/Symfony/Component/HttpKernel/Attribute/FromBody.php new file mode 100644 index 0000000000000..67344604e8b0f --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromBody.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's body. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromBody extends FromRequestParameter +{ + /** + * @param string|null $name The name of body parameter. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + * @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array + * @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array() + * @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any) + */ + public function __construct( + ?string $name = null, + int|array|null $filter = null, + int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL, + bool $throwOnFilterFailure = true, + ) { + parent::__construct('request', $name, $filter, $options, $throwOnFilterFailure); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromFile.php b/src/Symfony/Component/HttpKernel/Attribute/FromFile.php new file mode 100644 index 0000000000000..4033351b4a606 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromFile.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's files. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromFile extends FromRequestParameter +{ + /** + * @param string|null $name The name of route attribute. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + */ + public function __construct( + ?string $name = null, + ) { + parent::__construct('files', $name); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromHeader.php b/src/Symfony/Component/HttpKernel/Attribute/FromHeader.php new file mode 100644 index 0000000000000..2619452f6be3f --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromHeader.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's header. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromHeader extends FromRequestParameter +{ + /** + * @param string|null $name The name of header. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + * @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array + * @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array() + * @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any) + */ + public function __construct( + ?string $name = null, + int|array|null $filter = null, + int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL, + bool $throwOnFilterFailure = true, + ) { + parent::__construct('headers', $name, $filter, $options, $throwOnFilterFailure); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromQuery.php b/src/Symfony/Component/HttpKernel/Attribute/FromQuery.php new file mode 100644 index 0000000000000..3e1fa365254c3 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromQuery.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's query. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromQuery extends FromRequestParameter +{ + /** + * @param string|null $name The name of query parameter. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + * @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array + * @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array() + * @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any) + */ + public function __construct( + ?string $name = null, + int|array|null $filter = null, + int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL, + bool $throwOnFilterFailure = true, + ) { + parent::__construct('query', $name, $filter, $options, $throwOnFilterFailure); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromRequestParameter.php b/src/Symfony/Component/HttpKernel/Attribute/FromRequestParameter.php new file mode 100644 index 0000000000000..901d1adca1b4e --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromRequestParameter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's specified parameter bag. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromRequestParameter +{ + /** + * @param "attributes"|"request"|"query"|"headers"|"files" $bag The bag to consume the value from + * @param string|null $name The name of the parameter in bag. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + * @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array + * @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array() + * @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any) + */ + public function __construct( + public string $bag, + public ?string $name = null, + public int|array|null $filter = null, + public int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL, + public bool $throwOnFilterFailure = true, + ) { + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/FromRoute.php b/src/Symfony/Component/HttpKernel/Attribute/FromRoute.php new file mode 100644 index 0000000000000..64eb195b6626f --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/FromRoute.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Controller argument tag forcing ValueResolvers to use the value from request's route attributes. + * + * @author Mike Kulakovsky + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class FromRoute extends FromRequestParameter +{ + /** + * @param string|null $name The name of route attribute. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used. + * @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array + * @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array() + * @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any) + */ + public function __construct( + ?string $name = null, + int|array|null $filter = null, + int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL, + bool $throwOnFilterFailure = true, + ) { + parent::__construct('attributes', $name, $filter, $options, $throwOnFilterFailure); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php index 5b147b8143700..855aaeb28fe7f 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php @@ -20,6 +20,8 @@ * * @author Ruud Kamphuis * @author Ionut Enache + * + * @deprecated use #[FromQuery] instead */ #[\Attribute(\Attribute::TARGET_PARAMETER)] final class MapQueryParameter extends ValueResolver @@ -27,11 +29,11 @@ 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 + * @param string|null $name The name of the query parameter; if null, the name of the argument in the controller will be used * @param (FILTER_VALIDATE_*)|(FILTER_SANITIZE_*)|null $filter The filter to pass to "filter_var()" * @param int-mask-of<(FILTER_FLAG_*)|FILTER_NULL_ON_FAILURE> $flags The flags to pass to "filter_var()" - * @param array $options The options to pass to "filter_var()" - * @param class-string|string $resolver The name of the resolver to use + * @param array $options The options to pass to "filter_var()" + * @param class-string|string $resolver The name of the resolver to use */ public function __construct( public ?string $name = null, diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php index b09a92f02dab3..617cd63ed3e44 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php @@ -18,7 +18,6 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionValueResolver; -use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface; use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; @@ -135,7 +134,6 @@ public static function getDefaultArgumentValueResolvers(): iterable new RequestValueResolver(), new SessionValueResolver(), new DefaultValueResolver(), - new VariadicValueResolver(), ]; } } diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php index 9193cee060f69..5ac3542c3217d 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php @@ -11,38 +11,39 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** - * Attempt to resolve backed enum cases from request attributes, for a route path parameter, - * leading to a 404 Not Found if the attribute value isn't a valid backing value for the enum type. + * Attempt to resolve backed enum cases from request parameters, + * leading to a 404 Not Found if the parameter value isn't a valid backing value for the enum type. * * @author Maxime Steinhausser + * @author Mike Kulakovsky */ final class BackedEnumValueResolver implements ValueResolverInterface { - public function resolve(Request $request, ArgumentMetadata $argument): iterable - { - if (!is_subclass_of($argument->getType(), \BackedEnum::class)) { - return []; - } + use RequestParameterValueResolverTrait; - if ($argument->isVariadic()) { - // only target route path parameters, which cannot be variadic. - return []; - } + protected function supports(ArgumentMetadata $argument): bool + { + return is_subclass_of($argument->getType(), \BackedEnum::class); + } + protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array + { // do not support if no value can be resolved at all // letting the \Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver be used // or \Symfony\Component\HttpKernel\Controller\ArgumentResolver fail with a meaningful error. - if (!$request->attributes->has($argument->getName())) { + if (!$valueBag->has($argument->getName())) { return []; } - $value = $request->attributes->get($argument->getName()); + $value = $valueBag->get($argument->getName()); if (null === $value) { return [null]; diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/DateTimeValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/DateTimeValueResolver.php index 10ea8826f9d25..d5fe0f8c49ebd 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/DateTimeValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/DateTimeValueResolver.php @@ -12,32 +12,42 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Psr\Clock\ClockInterface; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapDateTime; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** - * Convert DateTime instances from request attribute variable. + * Convert DateTime instances from request parameter value. * * @author Benjamin Eberlei * @author Tim Goudriaan + * @author Mike Kulakovsky */ final class DateTimeValueResolver implements ValueResolverInterface { + use RequestParameterValueResolverTrait; + public function __construct( private readonly ?ClockInterface $clock = null, ) { } - public function resolve(Request $request, ArgumentMetadata $argument): array + protected function supports(ArgumentMetadata $argument): bool + { + return is_a($argument->getType(), \DateTimeInterface::class, true); + } + + protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array { - if (!is_a($argument->getType(), \DateTimeInterface::class, true) || !$request->attributes->has($argument->getName())) { + if (!$valueBag->has($argument->getName())) { return []; } - $value = $request->attributes->get($argument->getName()); + $value = $valueBag->get($argument->getName()); $class = \DateTimeInterface::class === $argument->getType() ? \DateTimeImmutable::class : $argument->getType(); if (!$value) { diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php index 8ceaba6da47dc..68f705f80c9c9 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php @@ -24,6 +24,8 @@ * @author Nicolas Grekas * @author Mateusz Anders * @author Ionut Enache + * + * @deprecated covered by RequestAttributeValueResolver and BackedEnumValueResolver with use of #[FromQuery] */ final class QueryParameterValueResolver implements ValueResolverInterface { diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php index 2a8d48ee30174..4fc3c25123c81 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php @@ -11,19 +11,24 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; /** - * Yields a non-variadic argument's value from the request attributes. + * Provides a value for controller argument of types: string, int, float, bool, array. * * @author Iltar van der Berg + * @author Mike Kulakovsky */ final class RequestAttributeValueResolver implements ValueResolverInterface { - public function resolve(Request $request, ArgumentMetadata $argument): array + use RequestParameterValueResolverTrait; + + protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array { - return !$argument->isVariadic() && $request->attributes->has($argument->getName()) ? [$request->attributes->get($argument->getName())] : []; + return $valueBag->has($argument->getName()) ? [$valueBag->get($argument->getName())] : []; } } diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php index 4a232eb8aeccf..eb6b537f779d1 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php @@ -11,24 +11,41 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Uid\AbstractUid; +/** + * Convert to Uid instance from request parameter value. + * + * @author Thomas Calvet + * @author Nicolas Grekas + * @author Mike Kulakovsky + */ final class UidValueResolver implements ValueResolverInterface { - public function resolve(Request $request, ArgumentMetadata $argument): array + use RequestParameterValueResolverTrait; + + protected function supports(ArgumentMetadata $argument): bool + { + $uidClass = $argument->getType(); + + return $uidClass && is_subclass_of($uidClass, AbstractUid::class, true); + } + + protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array { - if ($argument->isVariadic() - || !\is_string($value = $request->attributes->get($argument->getName())) - || null === ($uidClass = $argument->getType()) - || !is_subclass_of($uidClass, AbstractUid::class, true) - ) { + if (!$valueBag->has($argument->getName()) || !\is_string($value = $valueBag->get($argument->getName()))) { return []; } + /** @var class-string $uidClass */ + $uidClass = $argument->getType(); + try { return [$uidClass::fromString($value)]; } catch (\InvalidArgumentException $e) { diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/VariadicValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/VariadicValueResolver.php index d046129f4f455..58b108964570a 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/VariadicValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/VariadicValueResolver.php @@ -19,6 +19,9 @@ * Yields a variadic argument's values from the request attributes. * * @author Iltar van der Berg + * + * @deprecated Variadic parameters are now covered by RequestParameterValueResolverTrait + * @see \Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait */ final class VariadicValueResolver implements ValueResolverInterface { diff --git a/src/Symfony/Component/HttpKernel/Controller/RequestParameterValueResolverTrait.php b/src/Symfony/Component/HttpKernel/Controller/RequestParameterValueResolverTrait.php new file mode 100644 index 0000000000000..0f1c061ac62b5 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/RequestParameterValueResolverTrait.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller; + +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\FromRequestParameter; +use Symfony\Component\HttpKernel\Attribute\FromRoute; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +/** + * A common trait for ValueResolvers that resolve value from request parameters. + * Uses FromRequestParameter attribute to determine which request parameter to use. + * Supports filtering and validation via `filter_var`. + * + * Target ValueResolver must implement `resolveValue` method and use `$valueBag->get($argument->getName())` to get the value. + * Target ValueResolver should NOT have a separate logic for variadic arguments. This trait handles it. + * + * @see FromRoute + * @see \Symfony\Component\HttpKernel\Attribute\FromQuery + * @see \Symfony\Component\HttpKernel\Attribute\FromBody + * @see \Symfony\Component\HttpKernel\Attribute\FromHeader + * @see \Symfony\Component\HttpKernel\Attribute\FromFile + * + * @author Mike Kulakovsky + */ +trait RequestParameterValueResolverTrait +{ + public function resolve(Request $request, ArgumentMetadata $argument): array + { + return $this->resolveFromRequestParameters($request, $argument); + } + + protected function supports(ArgumentMetadata $argument): bool + { + return true; + } + + final protected function resolveFromRequestParameters(Request $request, ArgumentMetadata $argument): array + { + if (!$this->supports($argument)) { + return []; + } + + /** @var FromRequestParameter[] $attributes */ + $attributes = $argument->getAttributesOfType(FromRequestParameter::class, $argument::IS_INSTANCEOF); + + $attribute = match (\count($attributes)) { + 0 => new FromRoute(), // Fall back to route attributes by default to keep BC. + 1 => $attributes[0], + default => throw new \LogicException('Multiple FromRequestParameter attributes are not allowed on a single argument.'), + }; + + [$originalBag, $isOriginalBagCopied] = match ($attribute->bag) { + 'attributes' => [new ParameterBag($request->attributes->all()), true], + 'request' => [new ParameterBag($request->getPayload()->all()), true], + 'query' => [new ParameterBag($request->query->all()), true], + 'headers' => [$request->headers, false], + 'files' => [$request->files, false], + }; + + $requestParameterName = $attribute->name ?? $argument->getName(); + + if ('*' === $requestParameterName) { + // Complete bag contents, useful to get full query or payload as an array argument + $values = [$originalBag->all()]; + } elseif ($originalBag->has($requestParameterName)) { + $value = $originalBag->get($requestParameterName); + // Expand variadic only if a list of values provided in request data. Otherwise, treated as a single element. + if ($argument->isVariadic() && \is_array($value) && array_is_list($value)) { + $values = [...$value]; + } else { + $values = [$value]; + } + } else { + // No values bound to this argument, but we still need to call resolve + $values = []; + } + + $filter = $attribute->filter ?? match ($argument->getType()) { + 'int' => \FILTER_VALIDATE_INT, + 'float' => \FILTER_VALIDATE_FLOAT, + 'bool' => \FILTER_VALIDATE_BOOL, + 'string', 'array' => \FILTER_DEFAULT, + default => null, + }; + + if (null !== $filter && !empty($values)) { + $options = ['flags' => 0]; + if (\is_int($attribute->options)) { + $options['flags'] = $attribute->options; + } elseif (\is_array($attribute->options)) { + $options['options'] = $attribute->options['options'] ?? $attribute->options; + $options['flags'] = $attribute->options['flags'] ?? 0; + } + if (\FILTER_VALIDATE_BOOL === $filter) { + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + } + if ('array' === $argument->getType()) { + $options['flags'] |= \FILTER_FORCE_ARRAY | \FILTER_REQUIRE_ARRAY; + } + + $isNullOnEmptyString = $options['flags'] & \FILTER_FLAG_EMPTY_STRING_NULL; + $isNullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + + $failedValues = []; + + foreach ($values as $i => $value) { + if (\is_object($value)) { + continue; + } + + if (null === $value) { + unset($values[$i]); + continue; + } + + // Manually apply FILTER_FLAG_EMPTY_STRING_NULL to ignore empty string value for numeric arguments + if ('' === $value && $isNullOnEmptyString && (\FILTER_VALIDATE_INT === $filter || \FILTER_VALIDATE_FLOAT === $filter)) { + unset($values[$i]); + continue; + } + + if (\is_array($filter)) { + if (\is_array($value)) { + $filtered = filter_var_array($value, $filter, $options['add_empty'] ?? true); + } else { + $filtered = $isNullOnFailure ? null : false; + } + } else { + $filtered = filter_var($value, $filter, $options); + } + + if ((false === $filtered && !$isNullOnFailure) || (null === $filtered && $isNullOnFailure)) { + $failedValues[] = $value; + unset($values[$i]); + } else { + $values[$i] = $filtered; + } + } + + if ($failedValues && $attribute->throwOnFilterFailure) { + throw new BadRequestHttpException(\sprintf('Parameter "%s" is invalid.', $requestParameterName)); + } + } + + $baseParametersBag = $isOriginalBagCopied ? $originalBag : new ParameterBag(); + $parametersBags = []; + if ($values) { + // For variadic arguments, multiple bags are created, each with a single value + foreach ($values as $value) { + $bag = clone $baseParametersBag; + $bag->set($argument->getName(), $value); + $parametersBags[] = $bag; + } + } else { + $bag = clone $baseParametersBag; + // Ensure we do not pass unfiltered value + $bag->remove($argument->getName()); + $parametersBags = [$bag]; + } + + $resolvedValues = []; + foreach ($parametersBags as $bag) { + $resolved = $this->resolveValue($request, $argument, $bag); + if (1 === \count($resolved)) { + $resolvedValues[] = $resolved[0]; + } elseif (\count($resolved) > 1) { + throw new \UnexpectedValueException('Method resolveValue() should return an array with a single item containing resolved value or an empty array if nothing was resolved.'); + } + } + + return $resolvedValues; + } + + abstract protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array; +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php index 6b0f4027ffd8b..a9b8ca080f547 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php @@ -24,11 +24,11 @@ class BackedEnumValueResolverTest extends TestCase /** * @dataProvider provideTestSupportsData */ - public function testSupports(Request $request, ArgumentMetadata $metadata, bool $expectedSupport) + public function testSupports(Request $request, ArgumentMetadata $metadata, bool|int $expected) { $resolver = new BackedEnumValueResolver(); - $this->assertCount((int) $expectedSupport, $resolver->resolve($request, $metadata)); + $this->assertCount((int) $expected, $resolver->resolve($request, $metadata)); } public static function provideTestSupportsData(): iterable @@ -57,14 +57,24 @@ public static function provideTestSupportsData(): iterable false, ]; - yield 'unsupported variadic' => [ + yield 'supported variadic' => [ self::createRequest(['suit' => ['H', 'S']]), self::createArgumentMetadata( 'suit', Suit::class, variadic: true, ), - false, + 2, + ]; + + yield 'supported variadic as single' => [ + self::createRequest(['suit' => 'H']), + self::createArgumentMetadata( + 'suit', + Suit::class, + variadic: true, + ), + 1, ]; } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php index 1da4d976a2083..1f0be47a19788 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php @@ -35,9 +35,9 @@ public function testSupports(bool $expected, Request $request, ArgumentMetadata public static function provideSupports() { return [ - 'Variadic argument' => [false, new Request([], [], ['foo' => (string) $uuidV4 = new UuidV4()]), new ArgumentMetadata('foo', UuidV4::class, true, false, null)], + 'Variadic argument as single' => [true, new Request([], [], ['foo' => (string) $uuidV4 = new UuidV4()]), new ArgumentMetadata('foo', UuidV4::class, true, false, null)], + 'Variadic argument' => [true, new Request([], [], ['foo' => [(string) $uuidV4 = new UuidV4()]]), new ArgumentMetadata('foo', UuidV4::class, true, false, null)], 'No attribute for argument' => [false, new Request([], [], []), new ArgumentMetadata('foo', UuidV4::class, false, false, null)], - 'Attribute is not a string' => [false, new Request([], [], ['foo' => ['bar']]), new ArgumentMetadata('foo', UuidV4::class, false, false, null)], 'Argument has no type' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', null, false, false, null)], 'Argument type is not a class' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', 'string', false, false, null)], 'Argument type is not a subclass of AbstractUid' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', UlidFactory::class, false, false, null)], @@ -76,7 +76,7 @@ public static function provideResolveOK() /** * @dataProvider provideResolveKO */ - public function testResolveKO(string $requestUid, string $argumentType) + public function testResolveKO(mixed $requestUid, string $argumentType) { $this->expectException(NotFoundHttpException::class); $this->expectExceptionMessage('The uid for the "id" parameter is invalid.'); diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php index ed06130b92ec9..a0acc20f31320 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php @@ -169,13 +169,12 @@ public function testGetVariadicArguments() public function testGetVariadicArgumentsWithoutArrayInRequest() { - $this->expectException(\InvalidArgumentException::class); $request = Request::create('/'); $request->attributes->set('foo', 'foo'); $request->attributes->set('bar', 'foo'); $controller = [new VariadicController(), 'action']; - self::getResolver()->getArguments($request, $controller); + $this->assertEquals(['foo', 'foo'], self::getResolver()->getArguments($request, $controller)); } public function testIfExceptionIsThrownWhenMissingAnArgument() diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/RequestParameterValueResolverTraitTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/RequestParameterValueResolverTraitTest.php new file mode 100644 index 0000000000000..b6e2d301bc47e --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/RequestParameterValueResolverTraitTest.php @@ -0,0 +1,510 @@ + + * + * 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; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\FromBody; +use Symfony\Component\HttpKernel\Attribute\FromFile; +use Symfony\Component\HttpKernel\Attribute\FromHeader; +use Symfony\Component\HttpKernel\Attribute\FromQuery; +use Symfony\Component\HttpKernel\Attribute\FromRoute; +use Symfony\Component\HttpKernel\Attribute\MapDateTime; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface; +use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Tests\Fixtures\Suit; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\UuidV1; + +class RequestParameterValueResolverTraitTest extends TestCase +{ + protected ArgumentResolverInterface $argumentResolver; + + public static function intValues(): array + { + return [ + '123' => [123, 123], + '"123"' => [123, '123'], + '(empty string)' => [null, ''], + ]; + } + + public static function floatValues(): array + { + return [ + '1.23' => [1.23, 1.23], + '"1.23"' => [1.23, '1.23'], + '(empty string)' => [null, ''], + ]; + } + + public static function boolValues(): array + { + return [ + '"true"' => [true, 'true'], + '"1"' => [true, '1'], + '"on"' => [true, 'on'], + '"false"' => [false, 'false'], + '"off"' => [false, 'off'], + '"0"' => [false, '0'], + '(empty string)' => [false, ''], + ]; + } + + public function testStringDefault() + { + $args = $this->argumentResolver->getArguments( + new Request(attributes: ['foo' => 'bar']), + fn (string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testIgnoreWhenNoAttributes() + { + $this->expectExceptionMessage('requires the "$foo"'); + $this->argumentResolver->getArguments( + new Request(query: ['foo' => 'bar']), + fn (string $foo) => $foo, + ); + + $this->expectExceptionMessage('requires the "$foo"'); + $this->argumentResolver->getArguments( + new Request(request: ['foo' => 'bar']), + fn (string $foo) => $foo, + ); + } + + public function testStringFromRoute() + { + $args = $this->argumentResolver->getArguments( + new Request(attributes: ['foo' => 'bar']), + fn (#[FromRoute] string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testStringFromQuery() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => 'bar']), + fn (#[FromQuery] string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testStringFromBody() + { + $args = $this->argumentResolver->getArguments( + new Request(request: ['foo' => 'bar']), + fn (#[FromBody] string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testStringFromJsonBody() + { + $args = $this->argumentResolver->getArguments( + new Request(server: ['HTTP_CONTENT_TYPE' => 'application/json'], content: json_encode(['foo' => 'bar'])), + fn (#[FromBody] string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testStringFromHeader() + { + $args = $this->argumentResolver->getArguments( + new Request(server: ['HTTP_FOO' => 'bar']), + fn (#[FromHeader] string $foo) => $foo + ); + + $this->assertEquals(['bar'], $args); + } + + public function testStringFromQueryRenamed() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['theFoo' => 'bar']), + fn (#[FromQuery('theFoo')] string $foo) => $foo + ); + + $this->assertSame(['bar'], $args); + } + + public function testWholeBagAsParameter() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['sort' => 'name', 'order' => 'desc']), + fn (#[FromQuery('*')] array $sorting) => $sorting + ); + + $this->assertSame([['sort' => 'name', 'order' => 'desc']], $args); + } + + public function testStringFromHeaderRenamed() + { + $args = $this->argumentResolver->getArguments( + new Request(server: ['HTTP_X_FORWARDED_FOR' => 'bar']), + fn (#[FromHeader('x-forwarded-for')] string $foo) => $foo, + ); + $this->assertEquals(['bar'], $args); + } + + public function testFromFile() + { + $file = __DIR__.'/../Fixtures/Controller/ArgumentResolver/UploadedFile/file-small.txt'; + $request = new Request(files: [ + 'attach' => [ + 'name' => 'file-small.txt', + 'type' => 'text/plain', + 'tmp_name' => $file, + 'size' => filesize($file), + 'error' => \UPLOAD_ERR_OK, + ], + ]); + + $args = $this->argumentResolver->getArguments( + $request, + fn (#[FromFile] UploadedFile $attach) => $attach, + ); + $this->assertCount(1, $args); + $this->assertInstanceOf(UploadedFile::class, $args[0]); + $this->assertSame('file-small.txt', $args[0]->getClientOriginalName()); + } + + public function testInt() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['page' => '1']), + fn (#[FromQuery] int $page) => $page, + ); + $this->assertEquals([1], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['page' => '']), + fn (#[FromQuery] ?int $page) => $page, + ); + $this->assertEquals([null], $args); + } + + public function testValidationFailed() + { + $this->expectException(BadRequestHttpException::class); + $this->argumentResolver->getArguments( + new Request(query: ['page' => 'not-an-integer']), + fn (#[FromQuery()] ?int $page) => $page, + ); + } + + public function testEmptyStringAsNull() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['page' => '']), + fn (#[FromQuery] ?int $page) => $page, + ); + $this->assertEquals([null], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['weight' => '']), + fn (#[FromQuery] ?float $weight) => $weight, + ); + $this->assertEquals([null], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['title' => '']), + fn (#[FromQuery] ?int $title) => $title, + ); + $this->assertEquals([null], $args); + } + + public function testEmptyStringAsNullDisabled() + { + $this->expectException(BadRequestHttpException::class); + $this->argumentResolver->getArguments( + new Request(query: ['page' => '']), + fn (#[FromQuery(options: null)] ?int $page) => $page, + ); + } + + public function testMultipleAttributesDisallowed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Multiple'); + $this->argumentResolver->getArguments( + new Request(query: ['page' => 1], request: ['page' => 2]), + fn (#[FromQuery] #[FromBody] ?int $page) => $page, + ); + } + + /** @dataProvider intValues */ + public function testIntFromQuery($expected, $actual) + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => $actual]), + fn (#[FromQuery] ?int $int) => $int, + ); + $this->assertEquals([$expected], $args); + } + + /** @dataProvider floatValues */ + public function testFloatFromQuery($expected, $actual) + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['float' => $actual]), + fn (#[FromQuery] ?float $float) => $float, + ); + $this->assertEquals([$expected], $args); + } + + public function testFilterFlags() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => '0xff']), + fn (#[FromQuery(throwOnFilterFailure: false)] ?int $int) => $int, + ); + $this->assertEquals([null], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => '0xff']), + fn (#[FromQuery(options: \FILTER_FLAG_ALLOW_HEX)] ?int $int) => $int, + ); + $this->assertEquals([255], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => '0xff']), + fn (#[FromQuery(options: ['flags' => \FILTER_FLAG_ALLOW_HEX])] ?int $int) => $int, + ); + $this->assertEquals([255], $args); + } + + public function testFilterOptions() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => 42]), + fn (#[FromQuery] ?int $int) => $int, + ); + $this->assertEquals([42], $args); + + $this->expectException(BadRequestHttpException::class); + $this->argumentResolver->getArguments( + new Request(query: ['int' => 42]), + fn (#[FromQuery(options: ['max_range' => 10])] ?int $int) => $int, + ); + } + + public function testFilterFlagAndOptions() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['int' => 42]), + fn (#[FromQuery(options: ['flags' => \FILTER_FLAG_ALLOW_HEX, 'max_range' => 100])] ?int $int) => $int, + ); + $this->assertEquals([42], $args); + + $this->expectException(BadRequestHttpException::class); + $this->argumentResolver->getArguments( + new Request(query: ['int' => 42]), + fn (#[FromQuery(options: ['flags' => \FILTER_FLAG_ALLOW_HEX, 'max_range' => 10])] ?int $int) => $int, + ); + } + + /** @dataProvider boolValues */ + public function testBoolFromQuery($expected, $actual) + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['bool' => $actual]), + fn (#[FromQuery] bool $bool) => $bool, + ); + $this->assertEquals([$expected], $args); + } + + public function testBoolFromQueryMissing() + { + $args = $this->argumentResolver->getArguments( + new Request(query: []), + fn (#[FromQuery] ?bool $bool) => $bool, + ); + $this->assertEquals([null], $args); + + $this->expectExceptionMessage('requires the "$bool"'); + $args = $this->argumentResolver->getArguments( + new Request(query: []), + fn (#[FromQuery] bool $bool) => $bool, + ); + $this->assertEquals([null], $args); + } + + public function testUidFromQuery() + { + $uid = new UuidV1(); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['uid' => (string) $uid]), + fn (#[FromQuery] AbstractUid $uid) => $uid, + ); + $this->assertEquals([$uid], $args); + } + + public function testDateFromQuery() + { + $date = new \DateTimeImmutable('2021-01-01 01:02:03'); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['date' => $date->format(\DateTimeInterface::ATOM)]), + fn (#[FromQuery] \DateTimeInterface $date) => $date, + ); + $this->assertEquals([$date], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['date' => $date->getTimestamp()]), + fn (#[FromQuery] \DateTimeInterface $date) => $date, + ); + $this->assertEquals([$date], $args); + } + + public function testDateWithMapDateTimeFromQuery() + { + $date = new \DateTimeImmutable('2021-12-01 01:02:03'); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['date' => '2021/01/12 01:02:03']), + fn (#[FromQuery] #[MapDateTime(format: 'Y/d/m H:i:s')] \DateTimeInterface $date) => $date, + ); + $this->assertEquals([$date], $args); + } + + public function testEnumFromQuery() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['suit' => 'H']), + fn (#[FromQuery] Suit $suit) => $suit, + ); + $this->assertEquals([Suit::Hearts], $args); + } + + public function testArrayFromQuery() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => ['1', '2']]), + fn (#[FromQuery('foo')] array $foo) => $foo, + ); + $this->assertSame([['1', '2']], $args); + } + + public function testVariadic() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => ['1', '2']]), + fn (#[FromQuery('foo')] int ...$foo) => $foo, + ); + $this->assertSame([1, 2], $args); + + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => ['1', '0']]), + fn (#[FromQuery('foo')] bool ...$foo) => $foo, + ); + $this->assertSame([true, false], $args); + } + + public function testVariadicEnum() + { + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => ['H', 'S']]), + fn (#[FromQuery('foo')] Suit ...$foo) => $foo, + ); + $this->assertSame([Suit::Hearts, Suit::Spades], $args); + } + + public function testVariadicUid() + { + $uids = [ + new UuidV1(), + new UuidV1(), + ]; + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => [(string) $uids[0], (string) $uids[1]]]), + fn (#[FromQuery('foo')] AbstractUid ...$foo) => $foo, + ); + $this->assertEquals($uids, $args); + } + + public function testVariadicDateTime() + { + $dates = [ + new \DateTimeImmutable('2021-01-01 01:01:01'), + new \DateTimeImmutable('2021-02-02 02:02:02'), + ]; + $args = $this->argumentResolver->getArguments( + new Request(query: ['foo' => [$dates[0]->getTimestamp(), $dates[1]->format(\DATE_ATOM)]]), + fn (#[FromQuery('foo')] \DateTimeInterface ...$foo) => $foo, + ); + $this->assertEquals($dates, $args); + } + + public function testExtraParametersAreKept() + { + $resolver = new ArgumentResolver(new ArgumentMetadataFactory(), [new test_AllBagParametersValueResolver()]); + + $args = $resolver->getArguments( + new Request(attributes: ['country' => 'XX', 'area' => '42']), + fn ($area) => $area + ); + $this->assertSame([['country' => 'XX', 'area' => '42']], $args); + + $args = $resolver->getArguments( + new Request(attributes: ['country' => 'XX', 'area' => '42']), + fn (#[FromRoute] $area) => $area + ); + $this->assertSame([['country' => 'XX', 'area' => '42']], $args); + + $args = $resolver->getArguments( + new Request(query: ['country' => 'XX', 'area' => '42']), + fn (#[FromQuery] $area) => $area + ); + $this->assertSame([['country' => 'XX', 'area' => '42']], $args); + + $args = $resolver->getArguments( + new Request(request: ['country' => 'XX', 'area' => '42']), + fn (#[FromBody] $area) => $area + ); + $this->assertSame([['country' => 'XX', 'area' => '42']], $args); + } + + protected function setUp(): void + { + $this->argumentResolver = new ArgumentResolver( + new ArgumentMetadataFactory(), + [ + new ArgumentResolver\BackedEnumValueResolver(), + new ArgumentResolver\DateTimeValueResolver(), + new ArgumentResolver\UidValueResolver(), + ...ArgumentResolver::getDefaultArgumentValueResolvers(), + ] + ); + } +} + +class test_AllBagParametersValueResolver implements ValueResolverInterface +{ + use RequestParameterValueResolverTrait; + + public function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array + { + return [$valueBag->all()]; + } +}