diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index ada9fafe60102..7e96927d06eb1 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.2.0 +----- + + * added @QueryParam annotation to read query string from request + 5.1.0 ----- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php index 4285ba76319b2..644f05783d6b9 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParamValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionValueResolver; @@ -91,6 +92,7 @@ public static function getDefaultArgumentValueResolvers(): iterable new SessionValueResolver(), new DefaultValueResolver(), new VariadicValueResolver(), + new QueryParamValueResolver(), ]; } } diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParamValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParamValueResolver.php new file mode 100644 index 0000000000000..1deeb0248c257 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParamValueResolver.php @@ -0,0 +1,46 @@ +attributes->has('_configurations')) { + return false; + } + + /** @var QueryParam $configuration */ + $configuration = $this->getConfigurationForArgument($request, $argument); + + return null !== $configuration + && !$argument->isVariadic() + && ($request->query->has($configuration->getName()) || $argument->hasDefaultValue() || $argument->isNullable()); + } + + public function resolve(Request $request, ArgumentMetadata $argument) + { + $configuration = $this->getConfigurationForArgument($request, $argument); + $defaultValue = $argument->hasDefaultValue() ? $argument->getDefaultValue() : null; + yield $request->query->get($configuration->getName(), $defaultValue); + } + + private function getConfigurationForArgument(Request $request, ArgumentMetadata $argument): ?ConfigurationInterface + { + /** @var ConfigurationList $configurations */ + $configurations = $request->attributes->get('_configurations'); + + $configuration = $configurations->filter(function (ConfigurationInterface $configuration) use ($argument): bool { + return $configuration instanceof QueryParam && $configuration->getArgumentName() === $argument->getName(); + }); + + return $configuration->first(); + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationAnnotation.php b/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationAnnotation.php new file mode 100644 index 0000000000000..0f7741ad8f932 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationAnnotation.php @@ -0,0 +1,17 @@ + $v) { + if (!method_exists($this, $name = 'set'.$k)) { + throw new \RuntimeException(sprintf('Unknown key "%s" for annotation "@%s".', $k, static::class)); + } + + $this->$name($v); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationInterface.php b/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationInterface.php new file mode 100644 index 0000000000000..670ead2778f18 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/Configuration/ConfigurationInterface.php @@ -0,0 +1,8 @@ +name) { + $this->name = $this->argumentName; + } + } + + public function getName() + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function setValue(string $value): void + { + $this->setArgumentName($value); + } + + public function setArgumentName(string $argumentName): void + { + $this->argumentName = $argumentName; + } + + public function getArgumentName() + { + return $this->argumentName; + } + + public function getUniqueName(): string + { + return static::class.'.'.$this->argumentName; + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ConfigurationList.php b/src/Symfony/Component/HttpKernel/Controller/ConfigurationList.php new file mode 100644 index 0000000000000..2e953651957a8 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ConfigurationList.php @@ -0,0 +1,48 @@ +add($configuration); + } + } + + public function add(ConfigurationInterface $configuration): self + { + if (isset($this->configurations[$configuration->getUniqueName()])) { + throw new \LogicException(sprintf('Multiples "%s" configurations are not allowed', $configuration->getUniqueName())); + } + + $this->configurations[$configuration->getUniqueName()] = $configuration; + + return $this; + } + + public function filter(callable $filter): self + { + return new static(array_filter($this->configurations, $filter)); + } + + public function first(): ?ConfigurationInterface + { + return empty($this->configurations) ? null : reset($this->configurations); + } + + public function count(): int + { + return \count($this->configurations); + } + + public function getIterator() + { + return new \ArrayIterator($this->configurations); + } +} diff --git a/src/Symfony/Component/HttpKernel/EventListener/ControllerListener.php b/src/Symfony/Component/HttpKernel/EventListener/ControllerListener.php new file mode 100644 index 0000000000000..7260479e1405c --- /dev/null +++ b/src/Symfony/Component/HttpKernel/EventListener/ControllerListener.php @@ -0,0 +1,69 @@ + + */ +class ControllerListener implements EventSubscriberInterface +{ + /** + * @var Reader + */ + private $reader; + + public function __construct(Reader $reader) + { + $this->reader = $reader; + } + + public function onController(ControllerEvent $event): void + { + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return; + } + + $reflectionObject = new \ReflectionObject($controller[0]); + $reflectionMethod = $reflectionObject->getMethod($controller[1]); + + $configurations = new ConfigurationList(); + + $this->appendConfigurations($configurations, $this->reader->getClassAnnotations($reflectionObject)); + $this->appendConfigurations($configurations, $this->reader->getMethodAnnotations($reflectionMethod)); + + $event->getRequest()->attributes->set('_configurations', $configurations); + } + + private function appendConfigurations(ConfigurationList $configurations, array $annotations): void + { + foreach ($annotations as $annotation) { + if ($annotation instanceof ConfigurationInterface) { + $configurations->add($annotation); + } + } + } + + public static function getSubscribedEvents() + { + return [ + KernelEvents::CONTROLLER => 'onController', + ]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php index d663297121887..ff66dfd90d88c 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php @@ -19,7 +19,10 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\Configuration\QueryParam; +use Symfony\Component\HttpKernel\Controller\ConfigurationList; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\AnnotatedController; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingRequest; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingSession; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\NullableController; @@ -280,6 +283,42 @@ public function testGetSessionMissMatchOnNull() self::$resolver->getArguments($request, $controller); } + public function testGetQueryParam() + { + $request = Request::create('/?foo=foo&bar=bar'); + $request->attributes->set('_configurations', new ConfigurationList([ + new QueryParam(['value' => 'foo']), + new QueryParam(['value' => 'bar']), + ])); + $controller = [new AnnotatedController(), 'queryParam']; + + $this->assertEquals(['foo', 'bar'], self::$resolver->getArguments($request, $controller)); + } + + public function testGetQueryParamWithDefaultValues() + { + $request = Request::create('/'); + $request->attributes->set('_configurations', new ConfigurationList([ + new QueryParam(['value' => 'foo']), + new QueryParam(['value' => 'bar']), + ])); + $controller = [new AnnotatedController(), 'queryParamWithDefaultValues']; + + $this->assertEquals(['foo', 'bar'], self::$resolver->getArguments($request, $controller)); + } + + public function testGetQueryParamWithNullableValues() + { + $request = Request::create('/?foo=foo'); + $request->attributes->set('_configurations', new ConfigurationList([ + new QueryParam(['value' => 'foo']), + new QueryParam(['value' => 'bar']), + ])); + $controller = [new AnnotatedController(), 'queryParamWithNullableValues']; + + $this->assertEquals(['foo', null], self::$resolver->getArguments($request, $controller)); + } + public function __invoke($foo, $bar = null) { } diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/ControllerListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/ControllerListenerTest.php new file mode 100644 index 0000000000000..08252e9e694e9 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/ControllerListenerTest.php @@ -0,0 +1,50 @@ +reader = new AnnotationReader(); + $this->listener = new ControllerListener($this->reader); + } + + public function testOnController() + { + $event = $this->createControllerEvent([new AnnotatedController(), 'queryParam']); + $request = $event->getRequest(); + + $this->listener->onController($event); + + $this->assertTrue($request->attributes->has('_configurations')); + $this->assertCount(2, $request->attributes->get('_configurations')); + } + + public function testOnControllerWithDuplicatedQueryParam() + { + $this->expectException(\LogicException::class); + $event = $this->createControllerEvent([new AnnotatedController(), 'duplicatedQueryParamConfiguration']); + $this->listener->onController($event); + } + + private function createControllerEvent(callable $controller): ControllerEvent + { + $kernel = new KernelForTest('test', true); + $event = new ControllerEvent($kernel, $controller, new Request(), HttpKernelInterface::MASTER_REQUEST); + + return $event; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AnnotatedController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AnnotatedController.php new file mode 100644 index 0000000000000..116f52cbd1294 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/AnnotatedController.php @@ -0,0 +1,43 @@ +