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

  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\TwigComponent\ComponentFactory;
  32. use Symfony\UX\TwigComponent\ComponentMetadata;
  33. use Symfony\UX\TwigComponent\ComponentRenderer;
  34. use Symfony\UX\TwigComponent\MountedComponent;
  35. /**
  36.  * @author Kevin Bond <kevinbond@gmail.com>
  37.  * @author Ryan Weaver <ryan@symfonycasts.com>
  38.  *
  39.  * @experimental
  40.  *
  41.  * @internal
  42.  */
  43. class LiveComponentSubscriber implements EventSubscriberInterfaceServiceSubscriberInterface
  44. {
  45.     private const HTML_CONTENT_TYPE 'application/vnd.live-component+html';
  46.     private const REDIRECT_HEADER 'X-Live-Redirect';
  47.     public function __construct(private ContainerInterface $container)
  48.     {
  49.     }
  50.     public static function getSubscribedServices(): array
  51.     {
  52.         return [
  53.             ComponentRenderer::class,
  54.             ComponentFactory::class,
  55.             LiveComponentHydrator::class,
  56.             '?'.CsrfTokenManagerInterface::class,
  57.         ];
  58.     }
  59.     public function onKernelRequest(RequestEvent $event): void
  60.     {
  61.         $request $event->getRequest();
  62.         if (!$this->isLiveComponentRequest($request)) {
  63.             return;
  64.         }
  65.         if ($request->attributes->has('_controller')) {
  66.             return;
  67.         }
  68.         // the default "action" is get, which does nothing
  69.         $action $request->attributes->get('action''get');
  70.         $componentName = (string) $request->attributes->get('component');
  71.         $request->attributes->set('_component_name'$componentName);
  72.         try {
  73.             /** @var ComponentMetadata $metadata */
  74.             $metadata $this->container->get(ComponentFactory::class)->metadataFor($componentName);
  75.         } catch (\InvalidArgumentException $e) {
  76.             throw new NotFoundHttpException(sprintf('Component "%s" not found.'$componentName), $e);
  77.         }
  78.         if (!$metadata->get('live'false)) {
  79.             throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.'$metadata->getClass(), $componentName));
  80.         }
  81.         if ('get' === $action) {
  82.             $defaultAction trim($metadata->get('default_action''__invoke'), '()');
  83.             // set default controller for "default" action
  84.             $request->attributes->set('_controller'sprintf('%s::%s'$metadata->getServiceId(), $defaultAction));
  85.             $request->attributes->set('_component_default_action'true);
  86.             return;
  87.         }
  88.         if (!$request->isMethod('post')) {
  89.             throw new MethodNotAllowedHttpException(['POST']);
  90.         }
  91.         if (
  92.             $this->container->has(CsrfTokenManagerInterface::class) &&
  93.             $metadata->get('csrf') &&
  94.             !$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken($componentName$request->headers->get('X-CSRF-TOKEN')))) {
  95.             throw new BadRequestHttpException('Invalid CSRF token.');
  96.         }
  97.         if ('_batch' === $action) {
  98.             // use batch controller
  99.             $data $this->parseDataFor($request);
  100.             $request->attributes->set('_controller''ux.live_component.batch_action_controller');
  101.             $request->attributes->set('serviceId'$metadata->getServiceId());
  102.             $request->attributes->set('actions'$data['actions']);
  103.             $request->attributes->set('_mounted_component'$this->hydrateComponent(
  104.                 $this->container->get(ComponentFactory::class)->get($componentName),
  105.                 $componentName,
  106.                 $request
  107.             ));
  108.             $request->attributes->set('_is_live_batch_action'true);
  109.             return;
  110.         }
  111.         $request->attributes->set('_controller'sprintf('%s::%s'$metadata->getServiceId(), $action));
  112.     }
  113.     public function onKernelController(ControllerEvent $event): void
  114.     {
  115.         $request $event->getRequest();
  116.         if (!$this->isLiveComponentRequest($request)) {
  117.             return;
  118.         }
  119.         if ($request->attributes->get('_is_live_batch_action')) {
  120.             return;
  121.         }
  122.         $controller $event->getController();
  123.         if (!\is_array($controller) || !== \count($controller)) {
  124.             throw new \RuntimeException('Not a valid live component.');
  125.         }
  126.         [$component$action] = $controller;
  127.         if (!\is_object($component)) {
  128.             throw new \RuntimeException('Not a valid live component.');
  129.         }
  130.         if (!$request->attributes->get('_component_default_action'false) && !AsLiveComponent::isActionAllowed($component$action)) {
  131.             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)));
  132.         }
  133.         /*
  134.          * Either we:
  135.          *      A) We do NOT have a _mounted_component, so hydrate $component
  136.          *          (normal situation, rendering a single component)
  137.          *      B) We DO have a _mounted_component, so no need to hydrate,
  138.          *          but we DO need to make sure it's set as the controller.
  139.          *          (sub-request during batch controller)
  140.          */
  141.         if (!$request->attributes->has('_mounted_component')) {
  142.             $request->attributes->set('_mounted_component'$this->hydrateComponent(
  143.                 $component,
  144.                 $request->attributes->get('_component_name'),
  145.                 $request
  146.             ));
  147.         } else {
  148.             // override the component with our already-mounted version
  149.             $component $request->attributes->get('_mounted_component')->getComponent();
  150.             $event->setController([
  151.                 $component,
  152.                 $action,
  153.             ]);
  154.         }
  155.         // read the action arguments from the request, unless they're already set (batch sub-requests)
  156.         $actionArguments $request->attributes->get('_component_action_args'$this->parseDataFor($request)['args']);
  157.         // extra variables to be made available to the controller
  158.         // (for "actions" only)
  159.         foreach (LiveArg::liveArgs($component$action) as $parameter => $arg) {
  160.             if (isset($actionArguments[$arg])) {
  161.                 $request->attributes->set($parameter$actionArguments[$arg]);
  162.             }
  163.         }
  164.     }
  165.     /**
  166.      * @return array{
  167.      *     data: array,
  168.      *     args: array,
  169.      *     actions: array
  170.      *     childrenFingerprints: array
  171.      * }
  172.      */
  173.     private static function parseDataFor(Request $request): array
  174.     {
  175.         if (!$request->attributes->has('_live_request_data')) {
  176.             if ($request->query->has('data')) {
  177.                 $liveRequestData = [
  178.                     'data' => self::parseJsonFromQuery($request'data'),
  179.                     'args' => [],
  180.                     'actions' => [],
  181.                     'childrenFingerprints' => self::parseJsonFromQuery($request'childrenFingerprints'),
  182.                 ];
  183.             } else {
  184.                 $requestData $request->toArray();
  185.                 $liveRequestData = [
  186.                     'data' => $requestData['data'] ?? [],
  187.                     'args' => $requestData['args'] ?? [],
  188.                     'actions' => $requestData['actions'] ?? [],
  189.                     'childrenFingerprints' => $requestData['childrenFingerprints'] ?? [],
  190.                 ];
  191.             }
  192.             $request->attributes->set('_live_request_data'$liveRequestData);
  193.         }
  194.         return $request->attributes->get('_live_request_data');
  195.     }
  196.     public function onKernelView(ViewEvent $event): void
  197.     {
  198.         if (!$this->isLiveComponentRequest($request $event->getRequest())) {
  199.             return;
  200.         }
  201.         if (!$event->isMainRequest()) {
  202.             // sub-request, so skip rendering
  203.             $event->setResponse(new Response());
  204.             return;
  205.         }
  206.         $event->setResponse($this->createResponse($request->attributes->get('_mounted_component')));
  207.     }
  208.     public function onKernelException(ExceptionEvent $event): void
  209.     {
  210.         if (!$this->isLiveComponentRequest($request $event->getRequest())) {
  211.             return;
  212.         }
  213.         if (!$event->getThrowable() instanceof UnprocessableEntityHttpException) {
  214.             return;
  215.         }
  216.         // in case the exception was too early somehow
  217.         if (!$mounted $request->attributes->get('_mounted_component')) {
  218.             return;
  219.         }
  220.         $event->setResponse($this->createResponse($mounted));
  221.     }
  222.     public function onKernelResponse(ResponseEvent $event): void
  223.     {
  224.         $request $event->getRequest();
  225.         $response $event->getResponse();
  226.         if (!$this->isLiveComponentRequest($request)) {
  227.             return;
  228.         }
  229.         if (!\in_array(self::HTML_CONTENT_TYPE$request->getAcceptableContentTypes(), true)) {
  230.             return;
  231.         }
  232.         if (!$response->isRedirection()) {
  233.             return;
  234.         }
  235.         $event->setResponse(new Response(null204, [
  236.             'Location' => $response->headers->get('Location'),
  237.             self::REDIRECT_HEADER => 1,
  238.         ]));
  239.     }
  240.     public static function getSubscribedEvents(): array
  241.     {
  242.         return [
  243.             RequestEvent::class => 'onKernelRequest',
  244.             ControllerEvent::class => 'onKernelController',
  245.             ViewEvent::class => 'onKernelView',
  246.             ResponseEvent::class => 'onKernelResponse',
  247.             ExceptionEvent::class => 'onKernelException',
  248.         ];
  249.     }
  250.     private function createResponse(MountedComponent $mounted): Response
  251.     {
  252.         $component $mounted->getComponent();
  253.         foreach (AsLiveComponent::preReRenderMethods($component) as $method) {
  254.             $component->{$method->name}();
  255.         }
  256.         return new Response($this->container->get(ComponentRenderer::class)->render($mounted), 200, [
  257.             'Content-Type' => self::HTML_CONTENT_TYPE,
  258.         ]);
  259.     }
  260.     private function isLiveComponentRequest(Request $request): bool
  261.     {
  262.         return 'ux_live_component' === $request->attributes->get('_route');
  263.     }
  264.     private function hydrateComponent(object $componentstring $componentNameRequest $request): MountedComponent
  265.     {
  266.         $hydrator $this->container->get(LiveComponentHydrator::class);
  267.         \assert($hydrator instanceof LiveComponentHydrator);
  268.         $mountedComponent $hydrator->hydrate(
  269.             $component,
  270.             $this->parseDataFor($request)['data'],
  271.             $componentName
  272.         );
  273.         $mountedComponent->addExtraMetadata(
  274.             InterceptChildComponentRenderSubscriber::CHILDREN_FINGERPRINTS_METADATA_KEY,
  275.             $this->parseDataFor($request)['childrenFingerprints']
  276.         );
  277.         return $mountedComponent;
  278.     }
  279.     private static function parseJsonFromQuery(Request $requeststring $key): array
  280.     {
  281.         if (!$request->query->has($key)) {
  282.             return [];
  283.         }
  284.         try {
  285.             return json_decode($request->query->get($key), true512\JSON_THROW_ON_ERROR);
  286.         } catch (\JsonException $exception) {
  287.             throw new JsonException(sprintf('Invalid JSON on query string %s.'$key), 0$exception);
  288.         }
  289.     }
  290. }