From c252606a4bdc4a4c273b3c2864743f41e2c9a66a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 11 Jul 2022 15:08:45 +0200 Subject: [PATCH] [TwigBridge] Add `#[Template()]` to describe how to render arrays returned by controllers --- .../Bridge/Twig/Attribute/Template.php | 34 ++++++++ src/Symfony/Bridge/Twig/CHANGELOG.md | 1 + .../TemplateAttributeListener.php | 84 +++++++++++++++++++ .../TemplateAttributeListenerTest.php | 72 ++++++++++++++++ .../Fixtures/TemplateAttributeController.php | 22 +++++ src/Symfony/Bridge/Twig/composer.json | 4 +- .../TwigBundle/Resources/config/twig.php | 5 ++ src/Symfony/Bundle/TwigBundle/composer.json | 9 +- .../Event/ControllerArgumentsEvent.php | 28 +++++++ .../Component/HttpKernel/Event/ViewEvent.php | 4 +- .../Component/HttpKernel/HttpKernel.php | 4 +- .../IsGrantedAttributeListener.php | 22 ++--- 12 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 src/Symfony/Bridge/Twig/Attribute/Template.php create mode 100644 src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php create mode 100644 src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php create mode 100644 src/Symfony/Bridge/Twig/Tests/Fixtures/TemplateAttributeController.php diff --git a/src/Symfony/Bridge/Twig/Attribute/Template.php b/src/Symfony/Bridge/Twig/Attribute/Template.php new file mode 100644 index 0000000000000..f094f42a4a6e2 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Attribute/Template.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\Bridge\Twig\Attribute; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +class Template +{ + public function __construct( + /** + * The name of the template to render. + */ + public string $template, + + /** + * The controller method arguments to pass to the template. + */ + public ?array $vars = null, + + /** + * Enables streaming the template. + */ + public bool $stream = false, + ) { + } +} diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index e986f9b2d4166..b44911b9535ba 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `form_label_content` and `form_help_content` block to form themes + * Add `#[Template()]` to describe how to render arrays returned by controllers 6.1 --- diff --git a/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php b/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php new file mode 100644 index 0000000000000..96924442d1e48 --- /dev/null +++ b/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\EventListener; + +use Symfony\Bridge\Twig\Attribute\Template; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +class TemplateAttributeListener implements EventSubscriberInterface +{ + public function __construct( + private Environment $twig, + ) { + } + + public function onKernelView(ViewEvent $event) + { + $parameters = $event->getControllerResult(); + + if (!\is_array($parameters ?? [])) { + return; + } + $attribute = $event->getRequest()->attributes->get('_template'); + + if (!$attribute instanceof Template && !$attribute = $event->controllerArgumentsEvent?->getAttributes()[Template::class][0] ?? null) { + return; + } + + $parameters ??= $this->resolveParameters($event->controllerArgumentsEvent, $attribute->vars); + $status = 200; + + foreach ($parameters as $k => $v) { + if (!$v instanceof FormInterface) { + continue; + } + if ($v->isSubmitted() && !$v->isValid()) { + $status = 422; + } + $parameters[$k] = $v->createView(); + } + + $event->setResponse($attribute->stream + ? new StreamedResponse(fn () => $this->twig->display($attribute->template, $parameters), $status) + : new Response($this->twig->render($attribute->template, $parameters), $status) + ); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::VIEW => ['onKernelView', -128], + ]; + } + + private function resolveParameters(ControllerArgumentsEvent $event, ?array $vars): array + { + if ([] === $vars) { + return []; + } + + $parameters = $event->getNamedArguments(); + + if (null !== $vars) { + $parameters = array_intersect_key($parameters, array_flip($vars)); + } + + return $parameters; + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php b/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php new file mode 100644 index 0000000000000..87712ea51fb36 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; +use Symfony\Bridge\Twig\Tests\Fixtures\TemplateAttributeController; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Twig\Environment; + +class TemplateAttributeListenerTest extends TestCase +{ + public function testAttribute() + { + $twig = $this->createMock(Environment::class); + $twig->expects($this->exactly(2)) + ->method('render') + ->withConsecutive( + ['templates/foo.html.twig', ['foo' => 'bar']], + ['templates/foo.html.twig', ['bar' => 'Bar', 'buz' => 'def']] + ) + ->willReturn('Bar'); + + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $controllerArgumentsEvent = new ControllerArgumentsEvent($kernel, [new TemplateAttributeController(), 'foo'], ['Bar'], $request, null); + $listener = new TemplateAttributeListener($twig); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['foo' => 'bar'], $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertSame('Bar', $event->getResponse()->getContent()); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, null, $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertSame('Bar', $event->getResponse()->getContent()); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, null); + $listener->onKernelView($event); + $this->assertNull($event->getResponse()); + } + + public function testForm() + { + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $controllerArgumentsEvent = new ControllerArgumentsEvent($kernel, [new TemplateAttributeController(), 'foo'], [], $request, null); + $listener = new TemplateAttributeListener($this->createMock(Environment::class)); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('createView'); + $form->expects($this->once())->method('isSubmitted')->willReturn(true); + $form->expects($this->once())->method('isValid')->willReturn(false); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['bar' => $form], $controllerArgumentsEvent); + $listener->onKernelView($event); + + $this->assertSame(422, $event->getResponse()->getStatusCode()); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/TemplateAttributeController.php b/src/Symfony/Bridge/Twig/Tests/Fixtures/TemplateAttributeController.php new file mode 100644 index 0000000000000..3e69e0d2466cf --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/TemplateAttributeController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Fixtures; + +use Symfony\Bridge\Twig\Attribute\Template; + +class TemplateAttributeController +{ + #[Template('templates/foo.html.twig', vars: ['bar', 'buz'])] + public function foo($bar, $baz = 'abc', $buz = 'def') + { + } +} diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index f182dfa406417..09d372285b992 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -30,7 +30,7 @@ "symfony/form": "^6.1", "symfony/html-sanitizer": "^6.1", "symfony/http-foundation": "^5.4|^6.0", - "symfony/http-kernel": "^5.4|^6.0", + "symfony/http-kernel": "^6.2", "symfony/intl": "^5.4|^6.0", "symfony/mime": "^5.4|^6.0", "symfony/polyfill-intl-icu": "~1.0", @@ -58,7 +58,7 @@ "symfony/console": "<5.4", "symfony/form": "<6.1", "symfony/http-foundation": "<5.4", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.2", "symfony/translation": "<5.4", "symfony/workflow": "<5.4" }, diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php index 5536315306b72..cf8540764c7c4 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php @@ -15,6 +15,7 @@ use Symfony\Bridge\Twig\AppVariable; use Symfony\Bridge\Twig\DataCollector\TwigDataCollector; use Symfony\Bridge\Twig\ErrorRenderer\TwigErrorRenderer; +use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; use Symfony\Bridge\Twig\Extension\AssetExtension; use Symfony\Bridge\Twig\Extension\CodeExtension; use Symfony\Bridge\Twig\Extension\ExpressionExtension; @@ -169,5 +170,9 @@ ->args([service('serializer')]) ->set('twig.extension.serializer', SerializerExtension::class) + + ->set('controller.template_attribute_listener', TemplateAttributeListener::class) + ->args([service('twig')]) + ->tag('kernel.event_subscriber') ; }; diff --git a/src/Symfony/Bundle/TwigBundle/composer.json b/src/Symfony/Bundle/TwigBundle/composer.json index 379d95c2fd3cb..b587875e2529b 100644 --- a/src/Symfony/Bundle/TwigBundle/composer.json +++ b/src/Symfony/Bundle/TwigBundle/composer.json @@ -18,12 +18,11 @@ "require": { "php": ">=8.1", "composer-runtime-api": ">=2.1", - "symfony/config": "^5.4|^6.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/twig-bridge": "^5.4|^6.0", + "symfony/config": "^6.1", + "symfony/dependency-injection": "^6.1", + "symfony/twig-bridge": "^6.2", "symfony/http-foundation": "^5.4|^6.0", - "symfony/http-kernel": "^5.4|^6.0", - "symfony/polyfill-ctype": "~1.8", + "symfony/http-kernel": "^6.2", "twig/twig": "^2.13|^3.0.4" }, "require-dev": { diff --git a/src/Symfony/Component/HttpKernel/Event/ControllerArgumentsEvent.php b/src/Symfony/Component/HttpKernel/Event/ControllerArgumentsEvent.php index 4039924df6ec7..7004caa822753 100644 --- a/src/Symfony/Component/HttpKernel/Event/ControllerArgumentsEvent.php +++ b/src/Symfony/Component/HttpKernel/Event/ControllerArgumentsEvent.php @@ -30,6 +30,7 @@ final class ControllerArgumentsEvent extends KernelEvent { private ControllerEvent $controllerEvent; private array $arguments; + private array $namedArguments; public function __construct(HttpKernelInterface $kernel, callable|ControllerEvent $controller, array $arguments, Request $request, ?int $requestType) { @@ -54,6 +55,7 @@ public function getController(): callable public function setController(callable $controller, array $attributes = null): void { $this->controllerEvent->setController($controller, $attributes); + unset($this->namedArguments); } public function getArguments(): array @@ -64,6 +66,32 @@ public function getArguments(): array public function setArguments(array $arguments) { $this->arguments = $arguments; + unset($this->namedArguments); + } + + public function getNamedArguments(): array + { + if (isset($this->namedArguments)) { + return $this->namedArguments; + } + + $namedArguments = []; + $arguments = $this->arguments; + $r = $this->getRequest()->attributes->get('_controller_reflectors')[1] ?? new \ReflectionFunction($this->controllerEvent->getController()); + + foreach ($r->getParameters() as $i => $param) { + if ($param->isVariadic()) { + $namedArguments[$param->name] = \array_slice($arguments, $i); + break; + } + if (\array_key_exists($i, $arguments)) { + $namedArguments[$param->name] = $arguments[$i]; + } elseif ($param->isDefaultvalueAvailable()) { + $namedArguments[$param->name] = $param->getDefaultValue(); + } + } + + return $this->namedArguments = $namedArguments; } /** diff --git a/src/Symfony/Component/HttpKernel/Event/ViewEvent.php b/src/Symfony/Component/HttpKernel/Event/ViewEvent.php index d42e497a5c4f6..bf96985b29547 100644 --- a/src/Symfony/Component/HttpKernel/Event/ViewEvent.php +++ b/src/Symfony/Component/HttpKernel/Event/ViewEvent.php @@ -25,13 +25,15 @@ */ final class ViewEvent extends RequestEvent { + public readonly ?ControllerArgumentsEvent $controllerArgumentsEvent; private mixed $controllerResult; - public function __construct(HttpKernelInterface $kernel, Request $request, int $requestType, mixed $controllerResult) + public function __construct(HttpKernelInterface $kernel, Request $request, int $requestType, mixed $controllerResult, ControllerArgumentsEvent $controllerArgumentsEvent = null) { parent::__construct($kernel, $request, $requestType); $this->controllerResult = $controllerResult; + $this->controllerArgumentsEvent = $controllerArgumentsEvent; } public function getControllerResult(): mixed diff --git a/src/Symfony/Component/HttpKernel/HttpKernel.php b/src/Symfony/Component/HttpKernel/HttpKernel.php index 015d8b51f9e51..41e1700e4e8ce 100644 --- a/src/Symfony/Component/HttpKernel/HttpKernel.php +++ b/src/Symfony/Component/HttpKernel/HttpKernel.php @@ -156,14 +156,13 @@ private function handleRaw(Request $request, int $type = self::MAIN_REQUEST): Re $this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS); $controller = $event->getController(); $arguments = $event->getArguments(); - $request->attributes->remove('_controller_reflectors'); // call controller $response = $controller(...$arguments); // view if (!$response instanceof Response) { - $event = new ViewEvent($this, $request, $type, $response); + $event = new ViewEvent($this, $request, $type, $response, $event); $this->dispatcher->dispatch($event, KernelEvents::VIEW); if ($event->hasResponse()) { @@ -179,6 +178,7 @@ private function handleRaw(Request $request, int $type = self::MAIN_REQUEST): Re throw new ControllerDoesNotReturnResponseException($msg, $controller, __FILE__, __LINE__ - 17); } } + $request->attributes->remove('_controller_reflectors'); return $this->filterResponse($response, $request, $type); } diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php index d9c6830604024..8fcc4b8313485 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -38,19 +38,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event) return; } - $namedArguments = []; - $arguments = $event->getArguments(); - $r = $event->getRequest()->attributes->get('_controller_reflectors')[1] ?? new \ReflectionFunction($event->getController()); - - foreach ($r->getParameters() as $i => $param) { - if ($param->isVariadic()) { - $namedArguments[$param->name] = \array_slice($arguments, $i); - break; - } - if (\array_key_exists($i, $arguments)) { - $namedArguments[$param->name] = $arguments[$i]; - } - } + $arguments = $event->getNamedArguments(); foreach ($attributes as $attribute) { $subjectRef = $attribute->subject; @@ -59,15 +47,15 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event) if ($subjectRef) { if (\is_array($subjectRef)) { foreach ($subjectRef as $ref) { - if (!\array_key_exists($ref, $namedArguments)) { + if (!\array_key_exists($ref, $arguments)) { throw new \RuntimeException(sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $ref, $ref)); } - $subject[$ref] = $namedArguments[$ref]; + $subject[$ref] = $arguments[$ref]; } - } elseif (!\array_key_exists($subjectRef, $namedArguments)) { + } elseif (!\array_key_exists($subjectRef, $arguments)) { throw new \RuntimeException(sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your controller method.', $subjectRef, $subjectRef)); } else { - $subject = $namedArguments[$subjectRef]; + $subject = $arguments[$subjectRef]; } }