Unverified Commit 8ba75262 authored by Alex Pott's avatar Alex Pott
Browse files

task: #3580545 Make empty route lookup cacheable

By: tstoeckler
By: catch
By: berdir
(cherry picked from commit 01c0bc54)
parent a204e12f
Loading
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -1375,8 +1375,8 @@ services:
    arguments: ['@main_content_renderer.ajax', '@current_route_match', '@callable_resolver']
  Drupal\Core\Form\FormAjaxResponseBuilderInterface: '@form_ajax_response_builder'
  router_listener:
    class: Symfony\Component\HttpKernel\EventListener\RouterListener
    arguments: ['@router', '@request_stack', '@router.request_context', NULL]
    class: Drupal\Core\Http\EventListener\RouterListener
    arguments: ['@router', '@request_stack', '@router.request_context']
  options_request_listener:
    class: Drupal\Core\EventSubscriber\OptionsRequestSubscriber
    arguments: ['@router.route_provider']
+170 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Http\EventListener;

use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
use Drupal\Core\Routing\Exception\CacheableResourceNotFoundException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
use Symfony\Component\Routing\RequestContext;

/**
 * Sets request attributes based on a matching route.
 *
 * Also initializes the context from the request.
 *
 * In contrast to Symfony's RouterListener this converts a
 * CacheableResourceNoFoundException (thrown by Drupal's Router) into a
 * CacheableNotFoundHttpException. (Symfony already does the conversion of
 * ResourceNoFoundException into NotFoundHttpException.) Additionally, the
 * following functional things are changed (in addition to the code-style) to
 * reduce the complexity:
 * - It does not support URL matchers, only request matchers
 * - It does not support logging
 * - It does not return a welcome response
 *
 * @see \Symfony\Component\HttpKernel\EventListener\RouterListener
 * @see \Drupal\Core\Routing\Exception\CacheableResourceNotFoundException
 * @see \Drupal\Core\Routing\Router::matchRequest()
 */
class RouterListener implements EventSubscriberInterface {

  public function __construct(
    protected RequestMatcherInterface $matcher,
    protected RequestStack $requestStack,
    protected RequestContext $context,
  ) {}

  /**
   * Sets the request on the request context.
   *
   * @param ?\Symfony\Component\HttpFoundation\Request $request
   *   The request.
   */
  protected function setCurrentRequest(?Request $request): void {
    if ($request !== NULL) {
      try {
        $this->context->fromRequest($request);
      }
      catch (\UnexpectedValueException $e) {
        throw new BadRequestHttpException($e->getMessage(), $e, $e->getCode());
      }
    }
  }

  /**
   * Resets the routing context after a sub-request is done.
   */
  public function onKernelFinishRequest(): void {
    $this->setCurrentRequest($this->requestStack->getParentRequest());
  }

  /**
   * Sets request attributes based on a matching route.
   *
   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
   *   The request event.
   */
  public function onKernelRequest(RequestEvent $event): void {
    $request = $event->getRequest();

    $this->setCurrentRequest($request);

    if ($request->attributes->has('_controller')) {
      // Routing is already done.
      return;
    }

    // Add routing attributes based on the request.
    try {
      $parameters = $this->matcher->matchRequest($request);

      $attributes = $parameters;
      $mapping = $parameters['_route_mapping'] ?? FALSE;
      if ($mapping) {
        unset($parameters['_route_mapping']);
        $mappedAttributes = [];
        $attributes = [];

        foreach ($parameters as $parameter => $value) {
          if (!isset($mapping[$parameter])) {
            $attribute = $parameter;
          }
          elseif (is_array($mapping[$parameter])) {
            [$attribute, $parameter] = $mapping[$parameter];
            $mappedAttributes[$attribute] = '';
          }
          else {
            $attribute = $mapping[$parameter];
          }

          if (!isset($mappedAttributes[$attribute])) {
            $attributes[$attribute] = $value;
            $mappedAttributes[$attribute] = $parameter;
          }
          elseif ($mappedAttributes[$attribute] !== '') {
            $attributes[$attribute] = [
              $mappedAttributes[$attribute] => $attributes[$attribute],
              $parameter => $value,
            ];
            $mappedAttributes[$attribute] = '';
          }
          else {
            $attributes[$attribute][$parameter] = $value;
          }
        }

        $attributes['_route_mapping'] = $mapping;
      }

      $request->attributes->add($attributes);
      unset($parameters['_route'], $parameters['_controller']);
      $request->attributes->set('_route_params', $parameters);
    }
    catch (CacheableResourceNotFoundException $exception) {
      $message = sprintf('No route found for "%s %s"', $request->getMethod(), $request->getUriForPath($request->getPathInfo()));

      $referer = $request->headers->get('referer');
      if ($referer) {
        $message .= sprintf(' (from "%s")', $referer);
      }

      throw new CacheableNotFoundHttpException($exception, $message, $exception);
    }
    catch (ResourceNotFoundException $exception) {
      $message = sprintf('No route found for "%s %s"', $request->getMethod(), $request->getUriForPath($request->getPathInfo()));

      $referer = $request->headers->get('referer');
      if ($referer) {
        $message .= sprintf(' (from "%s")', $referer);
      }

      throw new NotFoundHttpException($message, $exception);
    }
    catch (MethodNotAllowedException $exception) {
      $message = sprintf('No route found for "%s %s": Method Not Allowed (Allow: %s)', $request->getMethod(), $request->getUriForPath($request->getPathInfo()), implode(', ', $exception->getAllowedMethods()));

      throw new MethodNotAllowedHttpException($exception->getAllowedMethods(), $message, $exception);
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::REQUEST => [['onKernelRequest', 32]],
      KernelEvents::FINISH_REQUEST => [['onKernelFinishRequest', 0]],
    ];
  }

}
+24 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Routing\Exception;

use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;

/**
 * A cacheable ResourceNotFoundException.
 */
class CacheableResourceNotFoundException extends ResourceNotFoundException implements CacheableDependencyInterface {

  use CacheableDependencyTrait;

  /**
   * {@inheritdoc}
   */
  public function __construct(CacheableDependencyInterface $cacheability, $message = '', $code = 0, ?\Throwable $previous = NULL) {
    $this->setCacheability($cacheability);
    parent::__construct($message, $code, $previous);
  }

}
+12 −1
Original line number Diff line number Diff line
@@ -2,8 +2,10 @@

namespace Drupal\Core\Routing;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Routing\Exception\CacheableResourceNotFoundException;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
@@ -111,7 +113,16 @@ public function match($pathinfo): array {
  public function matchRequest(Request $request): array {
    $collection = $this->getInitialRouteCollection($request);
    if ($collection->count() === 0) {
      throw new ResourceNotFoundException(sprintf('No routes found for "%s".', $this->currentPath->getPath()));
      // Allow caching the empty route lookup. Note that even though this result
      // inherently depends on the path we do not bubble any cache contexts
      // because:
      // 1. This is outside the scope of Dynamic Page Cache since we have not
      //    actually resolved a route
      // 2. Page Cache does not use cache contexts (and caches per URL anyway)
      throw new CacheableResourceNotFoundException(
        new CacheableMetadata(),
        sprintf('No routes found for "%s".', $this->currentPath->getPath()),
      );
    }
    $collection = $this->applyRouteFilters($collection, $request);
    $collection = $this->applyFitOrder($collection);
+6 −1
Original line number Diff line number Diff line
@@ -386,7 +386,9 @@ protected function testPageCacheAnonymous403404(): void {
      $this->drupalGet($content_url);
      $this->assertSession()->statusCodeEquals($code);
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
      $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', '4xx-response');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Tags', '4xx-response config:user.role.anonymous http_response rendered');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Contexts', 'languages:language_interface theme url.query_args:_wrapper_format user.permissions user.roles:authenticated');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Max-Age', '-1 (Permanent)');
      $this->drupalGet($content_url);
      $this->assertSession()->statusCodeEquals($code);
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT');
@@ -443,6 +445,9 @@ protected function testPageCacheAnonymous403404(): void {
      $this->drupalGet($content_url);
      $this->assertSession()->statusCodeEquals($code);
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Tags', '4xx-response config:user.role.anonymous http_response rendered');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Contexts', 'languages:language_interface theme url.query_args:_wrapper_format user.permissions user.roles:authenticated');
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Max-Age', '-1 (Permanent)');
    }
    // Restore 403 and 404 caching.
    $settings['settings']['cache_ttl_4xx'] = (object) [
Loading