diff --git a/core/core.services.yml b/core/core.services.yml index 317af02b89a3ade3a90fb24df0c79d1f019bfc01..c158be466098b3a798fb06a4cc2a56f8757065ea 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -278,6 +278,11 @@ services: arguments: ['@database', '@router.builder'] tags: - { name: event_subscriber } + router.route_preloader: + class: Drupal\Core\Routing\RoutePreloader + arguments: ['@router.route_provider', '@state', '@content_negotiation'] + tags: + - { name: 'event_subscriber' } router.matcher.final_matcher: class: Drupal\Core\Routing\UrlMatcher router.matcher: diff --git a/core/lib/Drupal/Core/Routing/RoutePreloader.php b/core/lib/Drupal/Core/Routing/RoutePreloader.php new file mode 100644 index 0000000000000000000000000000000000000000..daa41301477890fb5abd3450955eec16df33a574 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/RoutePreloader.php @@ -0,0 +1,132 @@ +<?php + +/** + * @file + * Contains \Drupal\Core\Routing\RoutePreloader. + */ + +namespace Drupal\Core\Routing; + +use Drupal\Core\ContentNegotiation; +use Drupal\Core\KeyValueStore\StateInterface; +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Defines a class which preloads non-admin routes. + * + * On an actual site we want to avoid too many database queries so we build a + * list of all routes which most likely appear on the actual site, which are all + * HTML routes not starting with "/admin". + */ +class RoutePreloader implements EventSubscriberInterface { + + /** + * The route provider. + * + * @var \Drupal\Core\Routing\RouteProviderInterface + */ + protected $routeProvider; + + /** + * The state key value store. + * + * @var \Drupal\Core\KeyValueStore\StateInterface + */ + protected $state; + + /** + * The content negotiation. + * + * @var \Drupal\Core\ContentNegotiation + */ + protected $negotiation; + + /** + * Contains the non-admin routes while rebuilding the routes. + * + * @var array + */ + protected $nonAdminRoutesOnRebuild = array(); + + /** + * Constructs a new RoutePreloader. + * + * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider + * The route provider. + * @param \Drupal\Core\KeyValueStore\StateInterface $state + * The state key value store. + * @param \Drupal\Core\ContentNegotiation $negotiation + * The content negotiation. + */ + public function __construct(RouteProviderInterface $route_provider, StateInterface $state, ContentNegotiation $negotiation) { + $this->routeProvider = $route_provider; + $this->state = $state; + $this->negotiation = $negotiation; + } + + /** + * Loads all non-admin routes right before the actual page is rendered. + * + * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event + * The event to process. + */ + public function onRequest(KernelEvent $event) { + // Just preload on normal HTML pages, as they will display menu links. + if ($this->negotiation->getContentType($event->getRequest()) == 'html') { + $this->loadNonAdminRoutes(); + } + } + + /** + * Load all the non-admin routes at once. + */ + protected function loadNonAdminRoutes() { + if ($routes = $this->state->get('routing.non_admin_routes', array())) { + $this->routeProvider->getRoutesByNames($routes); + } + } + + /** + * Alters existing routes for a specific collection. + * + * @param \Drupal\Core\Routing\RouteBuildEvent $event + * The route build event. + */ + public function onAlterRoutes(RouteBuildEvent $event) { + $collection = $event->getRouteCollection(); + foreach ($collection->all() as $name => $route) { + if (strpos($route->getPath(), '/admin/') !== 0 && $route->getPath() != '/admin') { + $this->nonAdminRoutesOnRebuild[] = $name; + } + } + $this->nonAdminRoutesOnRebuild = array_unique($this->nonAdminRoutesOnRebuild); + } + + /** + * Store the non admin routes in state when the route building is finished. + * + * @param \Symfony\Component\EventDispatcher\Event $event + * The route finish event. + */ + public function onFinishedRoutes(Event $event) { + $this->state->set('routing.non_admin_routes', $this->nonAdminRoutesOnRebuild); + $this->nonAdminRoutesOnRebuild = array(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Set a really low priority to catch as many as possible routes. + $events[RoutingEvents::ALTER] = array('onAlterRoutes', -1024); + $events[RoutingEvents::FINISHED] = array('onFinishedRoutes'); + // Load the routes before the controller is executed (which happens after + // the kernel request event). + $events[KernelEvents::REQUEST][] = array('onRequest'); + return $events; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Routing/RoutePreloaderTest.php b/core/tests/Drupal/Tests/Core/Routing/RoutePreloaderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..334e9039bd30485ecdb01ef423edcfa1033880f4 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Routing/RoutePreloaderTest.php @@ -0,0 +1,192 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\Core\Routing\RoutePreloaderTest. + */ + +namespace Drupal\Tests\Core\Routing; + +use Drupal\Core\Routing\RoutePreloader; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\EventDispatcher\Event; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * Tests the non admin routes preloader. + * + * @see \Drupal\Core\Routing\RoutePreloader + */ +class RoutePreloaderTest extends UnitTestCase { + + /** + * The mocked route provider. + * + * @var \Drupal\Core\Routing\RouteProviderInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected $routeProvider; + + /** + * The mocked state. + * + * @var \Drupal\Core\KeyValueStore\StateInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected $state; + + /** + * The mocked content negotiator. + * + * @var \Drupal\Core\ContentNegotiation|\PHPUnit_Framework_MockObject_MockObject + */ + protected $negotiation; + + /** + * The tested preloader. + * + * @var \Drupal\Core\Routing\RoutePreloader + */ + protected $preloader; + + /** + * {@inheritdoc} + */ + public static function getInfo() { + return array( + 'name' => 'Route preloader', + 'description' => 'Tests the non admin routes preloader.', + 'group' => 'Routing', + ); + } + + /** + * {@inheritdoc} + */ + protected function setUp() { + $this->routeProvider = $this->getMock('Drupal\Core\Routing\RouteProviderInterface'); + $this->state = $this->getMock('\Drupal\Core\KeyValueStore\StateInterface'); + $this->negotiation = $this->getMockBuilder('\Drupal\Core\ContentNegotiation') + ->disableOriginalConstructor() + ->getMock(); + $this->preloader = new RoutePreloader($this->routeProvider, $this->state, $this->negotiation); + } + + /** + * Tests onAlterRoutes with just admin routes. + */ + public function testOnAlterRoutesWithAdminRoutes() { + $event = $this->getMockBuilder('Drupal\Core\Routing\RouteBuildEvent') + ->disableOriginalConstructor() + ->getMock(); + $route_collection = new RouteCollection(); + $route_collection->add('test', new Route('/admin/foo', array('_content' => 'Drupal\ExampleController'))); + $route_collection->add('test2', new Route('/admin/bar', array('_content' => 'Drupal\ExampleController'))); + $event->expects($this->once()) + ->method('getRouteCollection') + ->will($this->returnValue($route_collection)); + + $this->state->expects($this->once()) + ->method('set') + ->with('routing.non_admin_routes', array()); + $this->preloader->onAlterRoutes($event); + $this->preloader->onFinishedRoutes(new Event()); + } + + /** + * Tests onAlterRoutes with "admin" appearing in the path. + */ + public function testOnAlterRoutesWithAdminPathNoAdminRoute() { + $event = $this->getMockBuilder('Drupal\Core\Routing\RouteBuildEvent') + ->disableOriginalConstructor() + ->getMock(); + $route_collection = new RouteCollection(); + $route_collection->add('test', new Route('/foo/admin/foo', array('_content' => 'Drupal\ExampleController'))); + $route_collection->add('test2', new Route('/bar/admin/bar', array('_content' => 'Drupal\ExampleController'))); + $route_collection->add('test3', new Route('/administrator/a', array('_content' => 'Drupal\ExampleController'))); + $route_collection->add('test4', new Route('/admin', array('_content' => 'Drupal\ExampleController'))); + $event->expects($this->once()) + ->method('getRouteCollection') + ->will($this->returnValue($route_collection)); + + $this->state->expects($this->once()) + ->method('set') + ->with('routing.non_admin_routes', array('test', 'test2', 'test3')); + $this->preloader->onAlterRoutes($event); + $this->preloader->onFinishedRoutes(new Event()); + } + + + /** + * Tests onAlterRoutes with admin routes and non admin routes. + */ + public function testOnAlterRoutesWithNonAdminRoutes() { + $event = $this->getMockBuilder('Drupal\Core\Routing\RouteBuildEvent') + ->disableOriginalConstructor() + ->getMock(); + $route_collection = new RouteCollection(); + $route_collection->add('test', new Route('/admin/foo', array('_content' => 'Drupal\ExampleController'))); + $route_collection->add('test2', new Route('/bar', array('_content' => 'Drupal\ExampleController'))); + // Non content routes, like ajax callbacks should be ignored. + $route_collection->add('test3', new Route('/bar', array('_controller' => 'Drupal\ExampleController'))); + $event->expects($this->once()) + ->method('getRouteCollection') + ->will($this->returnValue($route_collection)); + + $this->state->expects($this->once()) + ->method('set') + ->with('routing.non_admin_routes', array('test2', 'test3')); + $this->preloader->onAlterRoutes($event); + $this->preloader->onFinishedRoutes(new Event()); + } + + /** + * Tests onRequest on a non html request. + */ + public function testOnRequestNonHtml() { + $event = $this->getMockBuilder('\Symfony\Component\HttpKernel\Event\KernelEvent') + ->disableOriginalConstructor() + ->getMock(); + $request = new Request(); + $event->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + $this->negotiation->expects($this->once()) + ->method('getContentType') + ->will($this->returnValue('non-html')); + + $this->routeProvider->expects($this->never()) + ->method('getRoutesByNames'); + $this->state->expects($this->never()) + ->method('get'); + + $this->preloader->onRequest($event); + } + + /** + * Tests onRequest on a html request. + */ + public function testOnRequestOnHtml() { + $event = $this->getMockBuilder('\Symfony\Component\HttpKernel\Event\KernelEvent') + ->disableOriginalConstructor() + ->getMock(); + $request = new Request(); + $event->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + $this->negotiation->expects($this->once()) + ->method('getContentType') + ->will($this->returnValue('html')); + + $this->routeProvider->expects($this->once()) + ->method('getRoutesByNames') + ->with(array('test2')); + $this->state->expects($this->once()) + ->method('get') + ->with('routing.non_admin_routes') + ->will($this->returnValue(array('test2'))); + + $this->preloader->onRequest($event); + } + +}