vendor/symfony/ux-live-component/src/EventListener/LiveComponentSubscriber.php line 242

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\UX\LiveComponent\EventListener;
  11. use Psr\Container\ContainerInterface;
  12. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  13. use Symfony\Component\HttpFoundation\Exception\JsonException;
  14. use Symfony\Component\HttpFoundation\Request;
  15. use Symfony\Component\HttpFoundation\Response;
  16. use Symfony\Component\HttpKernel\Event\ControllerEvent;
  17. use Symfony\Component\HttpKernel\Event\ExceptionEvent;
  18. use Symfony\Component\HttpKernel\Event\RequestEvent;
  19. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  20. use Symfony\Component\HttpKernel\Event\ViewEvent;
  21. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  22. use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
  23. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  24. use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
  25. use Symfony\Component\Security\Csrf\CsrfToken;
  26. use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
  27. use Symfony\Contracts\Service\ServiceSubscriberInterface;
  28. use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
  29. use Symfony\UX\LiveComponent\Attribute\LiveArg;
  30. use Symfony\UX\LiveComponent\LiveComponentHydrator;
  31. use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
  32. use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
  33. use Symfony\UX\TwigComponent\ComponentFactory;
  34. use Symfony\UX\TwigComponent\ComponentMetadata;
  35. use Symfony\UX\TwigComponent\ComponentRenderer;
  36. use Symfony\UX\TwigComponent\MountedComponent;
  37. /**
  38.  * @author Kevin Bond <kevinbond@gmail.com>
  39.  * @author Ryan Weaver <ryan@symfonycasts.com>
  40.  *
  41.  * @experimental
  42.  *
  43.  * @internal
  44.  */
  45. class LiveComponentSubscriber implements EventSubscriberInterfaceServiceSubscriberInterface
  46. {
  47.     private const HTML_CONTENT_TYPE 'application/vnd.live-component+html';
  48.     private const REDIRECT_HEADER 'X-Live-Redirect';
  49.     public function __construct(private ContainerInterface $container)
  50.     {
  51.     }
  52.     public static function getSubscribedServices(): array
  53.     {
  54.         return [
  55.             ComponentRenderer::class,
  56.             ComponentFactory::class,
  57.             LiveComponentHydrator::class,
  58.             LiveComponentMetadataFactory::class,
  59.             '?'.CsrfTokenManagerInterface::class,
  60.         ];
  61.     }
  62.     public function onKernelRequest(RequestEvent $event): void
  63.     {
  64.         $request $event->getRequest();
  65.         if (!$this->isLiveComponentRequest($request)) {
  66.             return;
  67.         }
  68.         if ($request->attributes->has('_controller')) {
  69.             return;
  70.         }
  71.         // the default "action" is get, which does nothing
  72.         $action $request->attributes->get('_live_action''get');
  73.         $componentName = (string) $request->attributes->get('_live_component');
  74.         $request->attributes->set('_component_name'$componentName);
  75.         try {
  76.             /** @var ComponentMetadata $metadata */
  77.             $metadata $this->container->get(ComponentFactory::class)->metadataFor($componentName);
  78.         } catch (\InvalidArgumentException $e) {
  79.             throw new NotFoundHttpException(sprintf('Component "%s" not found.'$componentName), $e);
  80.         }
  81.         if (!$metadata->get('live'false)) {
  82.             throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.'$metadata->getClass(), $componentName));
  83.         }
  84.         if ('get' === $action) {
  85.             $defaultAction trim($metadata->get('default_action''__invoke'), '()');
  86.             // set default controller for "default" action
  87.             $request->attributes->set('_controller'sprintf('%s::%s'$metadata->getServiceId(), $defaultAction));
  88.             $request->attributes->set('_component_default_action'true);
  89.             return;
  90.         }
  91.         if (!$request->isMethod('post')) {
  92.             throw new MethodNotAllowedHttpException(['POST']);
  93.         }
  94.         if (
  95.             $this->container->has(CsrfTokenManagerInterface::class) &&
  96.             $metadata->get('csrf') &&
  97.             !$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken(LiveControllerAttributesCreator::getCsrfTokeName($componentName), $request->headers->get('X-CSRF-TOKEN')))) {
  98.             throw new BadRequestHttpException('Invalid CSRF token.');
  99.         }
  100.         if ('_batch' === $action) {
  101.             // use batch controller
  102.             $data $this->parseDataFor($request);
  103.             $request->attributes->set('_controller''ux.live_component.batch_action_controller');
  104.             $request->attributes->set('serviceId'$metadata->getServiceId());
  105.             $request->attributes->set('actions'$data['actions']);
  106.             $request->attributes->set('_mounted_component'$this->hydrateComponent(
  107.                 $this->container->get(ComponentFactory::class)->get($componentName),
  108.                 $componentName,
  109.                 $request
  110.             ));
  111.             $request->attributes->set('_is_live_batch_action'true);
  112.             return;
  113.         }
  114.         $request->attributes->set('_controller'sprintf('%s::%s'$metadata->getServiceId(), $action));
  115.     }
  116.     public function onKernelController(ControllerEvent $event): void
  117.     {
  118.         $request $event->getRequest();
  119.         if (!$this->isLiveComponentRequest($request)) {
  120.             return;
  121.         }
  122.         if ($request->attributes->get('_is_live_batch_action')) {
  123.             return;
  124.         }
  125.         $controller $event->getController();
  126.         if (!\is_array($controller) || !== \count($controller)) {
  127.             throw new \RuntimeException('Not a valid live component.');
  128.         }
  129.         [$component$action] = $controller;
  130.         if (!\is_object($component)) {
  131.             throw new \RuntimeException('Not a valid live component.');
  132.         }
  133.         if (!$request->attributes->get('_component_default_action'false) && !AsLiveComponent::isActionAllowed($component$action)) {
  134.             throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.'$action\get_class($component)));
  135.         }
  136.         /*
  137.          * Either we:
  138.          *      A) We do NOT have a _mounted_component, so hydrate $component
  139.          *          (normal situation, rendering a single component)
  140.          *      B) We DO have a _mounted_component, so no need to hydrate,
  141.          *          but we DO need to make sure it's set as the controller.
  142.          *          (sub-request during batch controller)
  143.          */
  144.         if (!$request->attributes->has('_mounted_component')) {
  145.             $request->attributes->set('_mounted_component'$this->hydrateComponent(
  146.                 $component,
  147.                 $request->attributes->get('_component_name'),
  148.                 $request
  149.             ));
  150.         } else {
  151.             // override the component with our already-mounted version
  152.             $component $request->attributes->get('_mounted_component')->getComponent();
  153.             $event->setController([
  154.                 $component,
  155.                 $action,
  156.             ]);
  157.         }
  158.         // read the action arguments from the request, unless they're already set (batch sub-requests)
  159.         $actionArguments $request->attributes->get('_component_action_args'$this->parseDataFor($request)['args']);
  160.         // extra variables to be made available to the controller
  161.         // (for "actions" only)
  162.         foreach (LiveArg::liveArgs($component$action) as $parameter => $arg) {
  163.             if (isset($actionArguments[$arg])) {
  164.                 $request->attributes->set($parameter$actionArguments[$arg]);
  165.             }
  166.         }
  167.     }
  168.     /**
  169.      * @return array{
  170.      *     data: array,
  171.      *     args: array,
  172.      *     actions: array
  173.      *     // has "fingerprint" and "tag" string key, keyed by component id
  174.      *     children: array
  175.      *     propsFromParent: array
  176.      * }
  177.      */
  178.     private static function parseDataFor(Request $request): array
  179.     {
  180.         if (!$request->attributes->has('_live_request_data')) {
  181.             if ($request->query->has('props')) {
  182.                 $liveRequestData = [
  183.                     'props' => self::parseJsonFromQuery($request'props'),
  184.                     'updated' => self::parseJsonFromQuery($request'updated'),
  185.                     'args' => [],
  186.                     'actions' => [],
  187.                     'children' => self::parseJsonFromQuery($request'children'),
  188.                     'propsFromParent' => self::parseJsonFromQuery($request'propsFromParent'),
  189.                 ];
  190.             } else {
  191.                 $requestData $request->toArray();
  192.                 $liveRequestData = [
  193.                     'props' => $requestData['props'] ?? [],
  194.                     'updated' => $requestData['updated'] ?? [],
  195.                     'args' => $requestData['args'] ?? [],
  196.                     'actions' => $requestData['actions'] ?? [],
  197.                     'children' => $requestData['children'] ?? [],
  198.                     'propsFromParent' => $requestData['propsFromParent'] ?? [],
  199.                 ];
  200.             }
  201.             $request->attributes->set('_live_request_data'$liveRequestData);
  202.         }
  203.         return $request->attributes->get('_live_request_data');
  204.     }
  205.     public function onKernelView(ViewEvent $event): void
  206.     {
  207.         if (!$this->isLiveComponentRequest($request $event->getRequest())) {
  208.             return;
  209.         }
  210.         if (!$event->isMainRequest()) {
  211.             // sub-request, so skip rendering
  212.             $event->setResponse(new Response());
  213.             return;
  214.         }
  215.         $event->setResponse($this->createResponse($request->attributes->get('_mounted_component')));
  216.     }
  217.     public function onKernelException(ExceptionEvent $event): void
  218.     {
  219.         if (!$this->isLiveComponentRequest($request $event->getRequest())) {
  220.             return;
  221.         }
  222.         if (!$event->getThrowable() instanceof UnprocessableEntityHttpException) {
  223.             return;
  224.         }
  225.         // in case the exception was too early somehow
  226.         if (!$mounted $request->attributes->get('_mounted_component')) {
  227.             return;
  228.         }
  229.         $event->setResponse($this->createResponse($mounted));
  230.     }
  231.     public function onKernelResponse(ResponseEvent $event): void
  232.     {
  233.         $request $event->getRequest();
  234.         $response $event->getResponse();
  235.         if (!$this->isLiveComponentRequest($request)) {
  236.             return;
  237.         }
  238.         if (!\in_array(self::HTML_CONTENT_TYPE$request->getAcceptableContentTypes(), true)) {
  239.             return;
  240.         }
  241.         if (!$response->isRedirection()) {
  242.             return;
  243.         }
  244.         $responseNoContent = new Response(nullResponse::HTTP_NO_CONTENT, [
  245.             self::REDIRECT_HEADER => '1',
  246.         ]);
  247.         $responseNoContent->headers->add($response->headers->all());
  248.         $event->setResponse($responseNoContent);
  249.     }
  250.     public static function getSubscribedEvents(): array
  251.     {
  252.         return [
  253.             RequestEvent::class => 'onKernelRequest',
  254.             // positive priority in case other ControllerEvent listeners need the attributes we set
  255.             ControllerEvent::class => ['onKernelController'10],
  256.             ViewEvent::class => 'onKernelView',
  257.             ResponseEvent::class => 'onKernelResponse',
  258.             // priority so that the exception is processed before it can be logged as an error
  259.             ExceptionEvent::class => ['onKernelException'20],
  260.         ];
  261.     }
  262.     private function createResponse(MountedComponent $mounted): Response
  263.     {
  264.         $component $mounted->getComponent();
  265.         foreach (AsLiveComponent::preReRenderMethods($component) as $method) {
  266.             $component->{$method->name}();
  267.         }
  268.         return new Response($this->container->get(ComponentRenderer::class)->render($mounted), 200, [
  269.             'Content-Type' => self::HTML_CONTENT_TYPE,
  270.         ]);
  271.     }
  272.     private function isLiveComponentRequest(Request $request): bool
  273.     {
  274.         return $request->attributes->has('_live_component');
  275.     }
  276.     private function hydrateComponent(object $componentstring $componentNameRequest $request): MountedComponent
  277.     {
  278.         $hydrator $this->container->get(LiveComponentHydrator::class);
  279.         \assert($hydrator instanceof LiveComponentHydrator);
  280.         $metadataFactory $this->container->get(LiveComponentMetadataFactory::class);
  281.         \assert($metadataFactory instanceof LiveComponentMetadataFactory);
  282.         $componentAttributes $hydrator->hydrate(
  283.             $component,
  284.             $this->parseDataFor($request)['props'],
  285.             $this->parseDataFor($request)['updated'],
  286.             $metadataFactory->getMetadata($componentName),
  287.             $this->parseDataFor($request)['propsFromParent']
  288.         );
  289.         $mountedComponent = new MountedComponent($componentName$component$componentAttributes);
  290.         $mountedComponent->addExtraMetadata(
  291.             InterceptChildComponentRenderSubscriber::CHILDREN_FINGERPRINTS_METADATA_KEY,
  292.             $this->parseDataFor($request)['children']
  293.         );
  294.         return $mountedComponent;
  295.     }
  296.     private static function parseJsonFromQuery(Request $requeststring $key): array
  297.     {
  298.         if (!$request->query->has($key)) {
  299.             return [];
  300.         }
  301.         try {
  302.             return json_decode($request->query->get($key), true512\JSON_THROW_ON_ERROR);
  303.         } catch (\JsonException $exception) {
  304.             throw new JsonException(sprintf('Invalid JSON on query string %s.'$key), 0$exception);
  305.         }
  306.     }
  307. }