core/core.services.yml | 43 ++++---- .../Core/Controller/MainContentControllerBase.php | 87 ---------------- .../Controller/MainContentControllerInterface.php | 92 ----------------- core/lib/Drupal/Core/CoreServiceProvider.php | 4 +- .../ContentControllerSubscriber.php | 51 +++------- .../ContentFormControllerSubscriber.php | 4 +- .../EventSubscriber/MainContentViewSubscriber.php | 109 ++++++++++++++++++++ .../Drupal/Core/Render/BareHtmlPageRenderer.php | 2 +- .../Core/Render/BareHtmlPageRendererInterface.php | 3 +- .../MainContent/AjaxRenderer.php} | 19 ++-- .../MainContent/DialogRenderer.php} | 69 +++++++------ .../MainContent/HtmlRenderer.php} | 53 +++++----- .../MainContent/MainContentRendererInterface.php | 38 +++++++ .../MainContentRenderersPass.php} | 14 +-- .../MainContent/ModalRenderer.php} | 26 +++-- core/modules/rest/src/Tests/ReadTest.php | 8 +- core/modules/rest/src/Tests/ResourceTest.php | 12 ++- .../system/src/Tests/Common/PageRenderTest.php | 8 +- .../ajax_test/src/Form/AjaxTestDialogForm.php | 2 +- .../modules/ajax_test/src/Form/AjaxTestForm.php | 2 +- core/modules/system/theme.api.php | 111 ++++++++++++--------- ...AjaxControllerTest.php => AjaxRendererTest.php} | 58 +++-------- 22 files changed, 389 insertions(+), 426 deletions(-) diff --git a/core/core.services.yml b/core/core.services.yml index 3aee566..52050cd 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -633,7 +633,7 @@ services: - { name: route_enhancer, priority: 20 } route_content_controller_subscriber: class: Drupal\Core\EventSubscriber\ContentControllerSubscriber - arguments: ['@content_negotiation', '%main_content_controllers%'] + arguments: ['@content_negotiation'] tags: - { name: event_subscriber } route_content_form_controller_subscriber: @@ -650,33 +650,34 @@ services: class: Drupal\Core\EventSubscriber\RouteMethodSubscriber tags: - { name: event_subscriber } - main_content_controller.base: - abstract: true - arguments: ['@controller_resolver'] - main_content_controller.html: - class: Drupal\Core\Controller\HtmlController - parent: main_content_controller.base + + # Main content view subscriber plus the renderers it uses. + main_content_view_subscriber: + class: Drupal\Core\EventSubscriber\MainContentViewSubscriber + arguments: ['@class_resolver', '@current_route_match', '%main_content_renderers%'] + tags: + - { name: event_subscriber } + main_content_renderer.html: + class: Drupal\Core\Render\MainContent\HtmlRenderer arguments: ['@title_resolver', '@plugin.manager.display_variant', '@event_dispatcher'] tags: - - { name: main_content_controller, format: html } - main_content_controller.ajax: - class: Drupal\Core\Controller\AjaxController - arguments: ['@controller_resolver', '@element_info'] - parent: main_content_controller.base + - { name: render.main_content_renderer, format: html } + main_content_renderer.ajax: + class: Drupal\Core\Render\MainContent\AjaxRenderer arguments: ['@element_info'] tags: - - { name: main_content_controller, format: drupal_ajax } - main_content_controller.dialog: - class: Drupal\Core\Controller\DialogController - parent: main_content_controller.base + - { name: render.main_content_renderer, format: drupal_ajax } + main_content_renderer.dialog: + class: Drupal\Core\Render\MainContent\DialogRenderer arguments: ['@title_resolver'] tags: - - { name: main_content_controller, format: drupal_dialog } - main_content_controller.modal: - class: Drupal\Core\Controller\ModalController - parent: main_content_controller.dialog + - { name: render.main_content_renderer, format: drupal_dialog } + main_content_renderer.modal: + class: Drupal\Core\Render\MainContent\ModalRenderer + arguments: ['@title_resolver'] tags: - - { name: main_content_controller, format: drupal_modal } + - { name: render.main_content_renderer, format: drupal_modal } + router_listener: class: Symfony\Component\HttpKernel\EventListener\RouterListener tags: diff --git a/core/lib/Drupal/Core/Controller/MainContentControllerBase.php b/core/lib/Drupal/Core/Controller/MainContentControllerBase.php deleted file mode 100644 index 64517fc..0000000 --- a/core/lib/Drupal/Core/Controller/MainContentControllerBase.php +++ /dev/null @@ -1,87 +0,0 @@ -controllerResolver = $controller_resolver; - } - - /** - * {@inheritdoc} - */ - public function getMainContent(Request $request, $controller_definition) { - if ($controller_definition instanceof \Closure) { - $callable = $controller_definition; - } - else { - $callable = $this->controllerResolver->getControllerFromDefinition($controller_definition); - } - $arguments = $this->controllerResolver->getArguments($request, $callable); - $main_content = call_user_func_array($callable, $arguments); - - return $main_content; - } - - /** - * {@inheritdoc} - */ - public function prepareContent(array $main_content, Request $request, RouteMatchInterface $route_match) { - // In this default implementation: - return [ - // We return $main_content verbatim. - $main_content, - // We don't provide a title. - NULL, - // No custom options. - [], - ]; - } - - /** - * {@inheritdoc} - */ - public function handle(Request $request, RouteMatchInterface $route_match, $_content) { - $main_content = $this->getMainContent($request, $_content); - - // If the received content already is a response, just pass it through. This - // may happen e.g. when a _content callable chooses to perform a redirect. - if ($main_content instanceof Response) { - return $main_content; - } - - if (!is_array($main_content)) { - throw new \LogicException('Invalid render array returned by ' . $_content . '.'); - } - - list($content, $title, $custom) = $this->prepareContent($main_content, $request, $route_match); - return $this->renderContentIntoResponse($content, $title, $custom); - } - -} diff --git a/core/lib/Drupal/Core/Controller/MainContentControllerInterface.php b/core/lib/Drupal/Core/Controller/MainContentControllerInterface.php deleted file mode 100644 index 02859f5..0000000 --- a/core/lib/Drupal/Core/Controller/MainContentControllerInterface.php +++ /dev/null @@ -1,92 +0,0 @@ -addCompilerPass(new StackedKernelPass()); - $container->addCompilerPass(new MainContentControllerPass()); + $container->addCompilerPass(new MainContentRenderersPass()); // Collect tagged handler services as method calls on consumer services. $container->addCompilerPass(new TaggedHandlersPass()); diff --git a/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php index a1f13ee..0d928705 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php @@ -15,26 +15,11 @@ /** * Defines a subscriber to negotiate a _controller to use for a _content route. * - * Given a route with a _content callback defined that returns "the content" (as - * a render array) to be returned at that route, this subscriber will determine - * which controller should render "the content" into an actual response (and set - * the _controller attribute on the the request). + * @todo Remove this event subscriber after both + * https://www.drupal.org/node/2092647 and https://www.drupal.org/node/2331919 + * have landed. * - * To do that, it will first determine which format to render "the content" in: - * that can be any of the MIME types that a render array can be transformed into. - * - * Additional target rendering formats can be defined by adding another service - * that implements \Drupal\Core\Controller\MainContentControllerInterface and - * tagging it as a @code main_content_controller @endcode, then - * \Drupal\Core\Render\MainContentControllerPass will detect it and use it when - * appropriate. - * - * Note: this also applies to routes that use _entity_view or _entity_form, for - * example, because those are "enhanced" into _content routes. - * - * @see \Drupal\Core\Controller\MainContentControllerInterface - * @see \Drupal\Core\Controller\MainContentControllerBase - * @see \Drupal\Core\Render\MainContentControllerPass + * @see \Drupal\Core\EventSubscriber\MainContentViewSubscriber */ class ContentControllerSubscriber implements EventSubscriberInterface { @@ -46,28 +31,20 @@ class ContentControllerSubscriber implements EventSubscriberInterface { protected $negotiation; /** - * The available main content controller services, keyed per format. - * - * @var array - */ - protected $mainContentControllers; - - /** * Constructs a new ContentControllerSubscriber object. * * @param \Drupal\Core\ContentNegotiation $negotiation * The Content Negotiation service. - * @param array $main_content_controllers - * The available main content controller services, keyed per format. */ - public function __construct(ContentNegotiation $negotiation, array $main_content_controllers) { + public function __construct(ContentNegotiation $negotiation) { $this->negotiation = $negotiation; - $this->mainContentControllers = $main_content_controllers; } /** * Sets the derived request format on the request. * + * @todo Remove when https://www.drupal.org/node/2331919 lands. + * * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event * The event to process. */ @@ -80,7 +57,9 @@ public function onRequestDeriveFormat(GetResponseEvent $event) { } /** - * Sets the derived _controller on the request, based on the request format. + * Sets _content (if it exists) as the _controller. + * + * @todo Remove when https://www.drupal.org/node/2092647 lands. * * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event * The event to process. @@ -89,13 +68,9 @@ public function onRequestDeriveController(GetResponseEvent $event) { $request = $event->getRequest(); $controller = $request->attributes->get('_controller'); - if (empty($controller) && ($format = $request->getRequestFormat())) { - if (isset($this->mainContentControllers[$format])) { - $controller = $this->mainContentControllers[$format]; - // MainContentControllerInterface dictates the method for handling a - // request is named ::handle(). Use 'service:method' notation. - $request->attributes->set('_controller', $controller . ':handle'); - } + $content = $request->attributes->get('_content'); + if (empty($controller) && !empty($content)) { + $request->attributes->set('_controller', $content); } } diff --git a/core/lib/Drupal/Core/EventSubscriber/ContentFormControllerSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ContentFormControllerSubscriber.php index 43bdd8b..b1d8411 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ContentFormControllerSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ContentFormControllerSubscriber.php @@ -57,6 +57,8 @@ public function __construct(ClassResolverInterface $class_resolver, ControllerRe /** * Sets the _controller on a request based on the request format. * + * @todo Remove when https://www.drupal.org/node/2092647 lands. + * * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event * The event to process. */ @@ -65,7 +67,7 @@ public function onRequestDeriveFormWrapper(GetResponseEvent $event) { if ($form = $request->attributes->get('_form')) { $wrapper = new HtmlFormController($this->classResolver, $this->controllerResolver, $this->container, $form, $this->formBuilder); - $request->attributes->set('_content', array($wrapper, 'getContentResult')); + $request->attributes->set('_controller', array($wrapper, 'getContentResult')); } } diff --git a/core/lib/Drupal/Core/EventSubscriber/MainContentViewSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/MainContentViewSubscriber.php new file mode 100644 index 0000000..0c386bf --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/MainContentViewSubscriber.php @@ -0,0 +1,109 @@ +classResolver = $class_resolver; + $this->routeMatch = $route_match; + $this->mainContentRenderers = $main_content_renderers; + } + + /** + * Sets a response given a (main content) render array. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent $event + * The event to process. + * + * @throws \LogicException + * Thrown when the _content controller doesn't return a render array. + */ + public function onViewRenderArray(GetResponseForControllerResultEvent $event) { + $request = $event->getRequest(); + $result = $event->getControllerResult(); + + $format = $request->getRequestFormat(); + + // Render the controller result into a response if it's a render array. + if (is_array($result)) { + if (isset($this->mainContentRenderers[$format])) { + $renderer = $this->classResolver->getInstanceFromDefinition($this->mainContentRenderers[$format]); + $event->setResponse($renderer->renderResponse($result, $request, $this->routeMatch)); + } + else { + $supported_formats = array_keys($this->mainContentRenderers); + $supported_mimetypes = array_map([$request, 'getMimeType'], $supported_formats); + $event->setResponse(new Response('Not Acceptable. Supported MIME types: ' . implode(', ', $supported_mimetypes) . '.', 406)); + } + } + } + + /** + * Registers the methods in this class that should be listeners. + * + * @return array + * An array of event listener definitions. + */ + static function getSubscribedEvents() { + $events[KernelEvents::VIEW][] = array('onViewRenderArray', 30); + + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Render/BareHtmlPageRenderer.php b/core/lib/Drupal/Core/Render/BareHtmlPageRenderer.php index 5ec957e..cd5adaa 100644 --- a/core/lib/Drupal/Core/Render/BareHtmlPageRenderer.php +++ b/core/lib/Drupal/Core/Render/BareHtmlPageRenderer.php @@ -70,7 +70,7 @@ protected function renderBarePage(array $content, $title, array $page_additions, ]; // We must first render the contents of the html.html.twig template, see - // \Drupal\Core\Controller\HtmlController::renderPage() for details. + // \Drupal\Core\Render\MainContent\HtmlRenderer::renderPage() for details. drupal_render_root($html['page']); return drupal_render($html); } diff --git a/core/lib/Drupal/Core/Render/BareHtmlPageRendererInterface.php b/core/lib/Drupal/Core/Render/BareHtmlPageRendererInterface.php index 5c9ddc2..4bba7c8 100644 --- a/core/lib/Drupal/Core/Render/BareHtmlPageRendererInterface.php +++ b/core/lib/Drupal/Core/Render/BareHtmlPageRendererInterface.php @@ -27,7 +27,8 @@ * i.e. use this when rendering HTML pages in limited environments. Otherwise, * use a @code _content @endcode route, thiswill cause a main content controller * (\Drupal\Core\ControllerMainContentControllerInterface) to be used, and in - * case of a HTML request that will be \Drupal\Core\Controller\HtmlController. + * case of a HTML request that will be + * \Drupal\Core\Render\MainContent\HtmlRenderer. * * Currently, there are two types of bare pages available: * 1. install (hook_preprocess_install_page(), install-page.html.twig) diff --git a/core/lib/Drupal/Core/Controller/AjaxController.php b/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php similarity index 78% rename from core/lib/Drupal/Core/Controller/AjaxController.php rename to core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php index 42d57b0..728f615 100644 --- a/core/lib/Drupal/Core/Controller/AjaxController.php +++ b/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php @@ -2,21 +2,23 @@ /** * @file - * Contains \Drupal\Core\Controller\AjaxController. + * Contains \Drupal\Core\Render\MainContent\AjaxRenderer. */ -namespace Drupal\Core\Controller; +namespace Drupal\Core\Render\MainContent; use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\AlertCommand; use Drupal\Core\Ajax\InsertCommand; use Drupal\Core\Ajax\PrependCommand; use Drupal\Core\Render\ElementInfoManagerInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Symfony\Component\HttpFoundation\Request; /** - * Default controller for Ajax requests. + * Default main content renderer for Ajax requests. */ -class AjaxController extends MainContentControllerBase { +class AjaxRenderer { /** * The controller resolver. @@ -33,22 +35,19 @@ class AjaxController extends MainContentControllerBase { protected $elementInfoManager; /** - * Constructs a new AjaxController instance. + * Constructs a new AjaxRenderer instance. * - * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver - * The controller resolver. * @param \Drupal\Core\Render\ElementInfoManagerInterface $element_info_manager * The element info manager. */ - public function __construct(ControllerResolverInterface $controller_resolver, ElementInfoManagerInterface $element_info_manager) { - parent::__construct($controller_resolver); + public function __construct(ElementInfoManagerInterface $element_info_manager) { $this->elementInfoManager = $element_info_manager; } /** * {@inheritdoc} */ - public function renderContentIntoResponse(array $main_content, $title, array $custom) { + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { $response = new AjaxResponse(); if (isset($main_content['#type']) && ($main_content['#type'] == 'ajax')) { diff --git a/core/lib/Drupal/Core/Controller/DialogController.php b/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php similarity index 51% rename from core/lib/Drupal/Core/Controller/DialogController.php rename to core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php index 655354d..24462cb 100644 --- a/core/lib/Drupal/Core/Controller/DialogController.php +++ b/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php @@ -2,20 +2,21 @@ /** * @file - * Contains \Drupal\Core\Controller\DialogController. + * Contains \Drupal\Core\Render\MainContent\DialogRenderer. */ -namespace Drupal\Core\Controller; +namespace Drupal\Core\Render\MainContent; use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\OpenDialogCommand; +use Drupal\Core\Controller\TitleResolverInterface; use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\HttpFoundation\Request; /** - * Default controller for dialog requests. + * Default main content renderer for dialog requests. */ -class DialogController extends MainContentControllerBase { +class DialogRenderer { /** * The title resolver. @@ -25,24 +26,50 @@ class DialogController extends MainContentControllerBase { protected $titleResolver; /** - * Constructs a new DialogController. + * Constructs a new DialogRenderer. * - * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver - * The controller resolver service. * @param \Drupal\Core\Controller\TitleResolverInterface $title_resolver * The title resolver. */ - public function __construct(ControllerResolverInterface $controller_resolver, TitleResolverInterface $title_resolver) { - parent::__construct($controller_resolver); + public function __construct(TitleResolverInterface $title_resolver) { $this->titleResolver = $title_resolver; } /** * {@inheritdoc} */ - public function prepareContent(array $main_content, Request $request, RouteMatchInterface $route_match) { + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + $response = new AjaxResponse(); + + // First render the main content, because it might provide a title. + $content = drupal_render_root($main_content); + drupal_process_attached($main_content); + + // Determine the title: use the title provided by the main content if any, + // otherwise get it from the routing information. + $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject()); + // Determine the dialog options and the target for the OpenDialogCommand. $options = $request->request->get('dialogOptions', array()); + $target = $this->determineTargetSelector($options, $route_match); + + $response->addCommand(new OpenDialogCommand($target, $title, $content, $options)); + return $response; + } + + /** + * Determine the target selector for the OpenDialogCommand. + * + * @param array &$options + * The 'target' option, if set, is used, and then removed from $options. + * @param RouteMatchInterface $route_match + * When no 'target' option is set in $options, $route_match is used instead + * to determine the target. + * + * @return string + * The target selector. + */ + protected function determineTargetSelector(array &$options, RouteMatchInterface $route_match) { // Generate the target wrapper for the dialog. if (isset($options['target'])) { // If the target was nominated in the incoming options, use that. @@ -59,27 +86,7 @@ public function prepareContent(array $main_content, Request $request, RouteMatch $route_name = $route_match->getRouteName(); $target = '#' . drupal_html_id("drupal-dialog-$route_name"); } - - return [ - $main_content, - isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject()), - [ - 'dialog_options' => $options, - 'target' => $target, - ] - ]; - } - - /** - * {@inheritdoc} - */ - public function renderContentIntoResponse(array $main_content, $title, array $custom) { - $content = drupal_render_root($main_content); - drupal_process_attached($main_content); - - $response = new AjaxResponse(); - $response->addCommand(new OpenDialogCommand($custom['target'], $title, $content, $custom['dialog_options'])); - return $response; + return $target; } } diff --git a/core/lib/Drupal/Core/Controller/HtmlController.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php similarity index 85% rename from core/lib/Drupal/Core/Controller/HtmlController.php rename to core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php index 79bce4a..47e33bb 100644 --- a/core/lib/Drupal/Core/Controller/HtmlController.php +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php @@ -2,14 +2,15 @@ /** * @file - * Contains \Drupal\Core\Controller\HtmlController. + * Contains \Drupal\Core\Render\MainContent\HtmlRenderer. */ -namespace Drupal\Core\Controller; +namespace Drupal\Core\Render\MainContent; use Drupal\Component\Plugin\PluginManagerInterface; use Drupal\Component\Utility\SafeMarkup; use Drupal\Core\Cache\Cache; +use Drupal\Core\Controller\TitleResolverInterface; use Drupal\Core\Display\PageVariantInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\system\Event\PageDisplayVariantSelectionEvent; @@ -20,9 +21,9 @@ use Symfony\Component\HttpFoundation\Response; /** - * Default controller for HTML requests. + * Default main content renderer for HTML requests. */ -class HtmlController extends MainContentControllerBase { +class HtmlRenderer { /** * The title resolver. @@ -46,10 +47,8 @@ class HtmlController extends MainContentControllerBase { protected $eventDispatcher; /** - * Constructs a new HtmlController. + * Constructs a new HtmlRenderer. * - * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver - * The controller resolver. * @param \Drupal\Core\Controller\TitleResolverInterface $title_resolver * The title resolver. * @param \Drupal\Component\Plugin\PluginManagerInterface $display_variant_manager @@ -57,19 +56,31 @@ class HtmlController extends MainContentControllerBase { * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher. */ - public function __construct(ControllerResolverInterface $controller_resolver, TitleResolverInterface $title_resolver, PluginManagerInterface $display_variant_manager, EventDispatcherInterface $event_dispatcher) { - parent::__construct($controller_resolver); + public function __construct(TitleResolverInterface $title_resolver, PluginManagerInterface $display_variant_manager, EventDispatcherInterface $event_dispatcher) { $this->titleResolver = $title_resolver; $this->displayVariantManager = $display_variant_manager; $this->eventDispatcher = $event_dispatcher; } /** - * {@inheritdoc} + * Prepares the HTML body: wraps the main content in #type 'page'. + * + * @param array $main_content + * The render array representing the main content. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object, for context. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match, for context. * - * The HTML body: wraps the main content in #type 'page'. + * @return array + * An array with two values: + * 0. A #type 'page' render array. + * 1. The page title. + * + * @throws \LogicException + * If the selected display variant does not implement PageVariantInterface. */ - public function prepareContent(array $main_content, Request $request, RouteMatchInterface $route_match) { + protected function prepare(array $main_content, Request $request, RouteMatchInterface $route_match) { // If the _content result already is #type => page, we have no work to do: // the "main content" already is an entire "page" (see html.html.twig). if (isset($main_content['#type']) && $main_content['#type'] === 'page') { @@ -129,17 +140,11 @@ public function prepareContent(array $main_content, Request $request, RouteMatch // Allow hooks to add attachments to $page['#attached']. static::invokePageAttachmentHooks($page); - // Determine the title: use the provided one if available, otherwise get it - // from the routing information. - $title = NULL; - if (isset($main_content['#title'])) { - $title = $main_content['#title']; - } - else { - $title = $this->titleResolver->getTitle($request, $route_match->getRouteObject()); - } + // Determine the title: use the title provided by the main content if any, + // otherwise get it from the routing information. + $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject()); - return [$page, $title, []]; + return [$page, $title]; } /** @@ -147,7 +152,9 @@ public function prepareContent(array $main_content, Request $request, RouteMatch * * The entire HTML: takes a #type 'page' and wraps it in a #type 'html'. */ - public function renderContentIntoResponse(array $page, $title, array $custom) { + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + list($page, $title) = $this->prepare($main_content, $request, $route_match); + if (!isset($page['#type']) || $page['#type'] !== 'page') { throw new \LogicException('Must be #type page'); } diff --git a/core/lib/Drupal/Core/Render/MainContent/MainContentRendererInterface.php b/core/lib/Drupal/Core/Render/MainContent/MainContentRendererInterface.php new file mode 100644 index 0000000..ca957c8 --- /dev/null +++ b/core/lib/Drupal/Core/Render/MainContent/MainContentRendererInterface.php @@ -0,0 +1,38 @@ +findTaggedServiceIds('main_content_controller') as $id => $attributes) { + foreach ($container->findTaggedServiceIds('render.main_content_renderer') as $id => $attributes) { $format = $attributes[0]['format']; $main_content_renderers[$format] = $id; } - $container->setParameter('main_content_controllers', $main_content_renderers); + $container->setParameter('main_content_renderers', $main_content_renderers); } } diff --git a/core/lib/Drupal/Core/Controller/ModalController.php b/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php similarity index 27% rename from core/lib/Drupal/Core/Controller/ModalController.php rename to core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php index ed1adbd..c971eb3 100644 --- a/core/lib/Drupal/Core/Controller/ModalController.php +++ b/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php @@ -2,28 +2,40 @@ /** * @file - * Contains \Drupal\Core\Controller\ModalController. + * Contains \Drupal\Core\Render\MainContent\ModalRenderer. */ -namespace Drupal\Core\Controller; +namespace Drupal\Core\Render\MainContent; use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\OpenModalDialogCommand; +use Drupal\Core\Render\MainContent\DialogRenderer; +use Drupal\Core\Routing\RouteMatchInterface; +use Symfony\Component\HttpFoundation\Request; /** - * Default controller for modal dialog requests. + * Default main content renderer for modal dialog requests. */ -class ModalController extends DialogController { +class ModalRenderer extends DialogRenderer { /** * {@inheritdoc} */ - public function renderContentIntoResponse(array $main_content, $title, array $custom) { + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + $response = new AjaxResponse(); + + // First render the main content, because it might provide a title. $content = drupal_render_root($main_content); drupal_process_attached($main_content); - $response = new AjaxResponse(); - $response->addCommand(new OpenModalDialogCommand($title, $content, $custom['dialog_options'])); + // If the main content doesn't provide a title, use the title resolver. + $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject()); + + // Determine the title: use the title provided by the main content if any, + // otherwise get it from the routing information. + $options = $request->request->get('dialogOptions', array()); + + $response->addCommand(new OpenModalDialogCommand($title, $content, $options)); return $response; } diff --git a/core/modules/rest/src/Tests/ReadTest.php b/core/modules/rest/src/Tests/ReadTest.php index e1aae7f..062529c 100644 --- a/core/modules/rest/src/Tests/ReadTest.php +++ b/core/modules/rest/src/Tests/ReadTest.php @@ -87,8 +87,12 @@ public function testRead() { $account = $this->drupalCreateUser(); $this->drupalLogin($account); $response = $this->httpRequest($account->getSystemPath(), 'GET', NULL, $this->defaultMimeType); - $this->assertResponse(404); - $expected_message = Json::encode(['error' => 'A fatal error occurred: Unable to find the controller for path "/user/4". Maybe you forgot to add the matching route in your routing configuration?']); + // AcceptHeaderMatcher considers the canonical, non-REST route a match, but + // a lower quality one: no format restrictions means there's always a match, + // and hence when there is no matching REST route, the non-REST route is + // used, but it can't render into application/hal+json, so it returns a 406. + $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.'); + $expected_message = 'Not Acceptable. Supported MIME types: text/html, application/vnd.drupal-ajax, application/vnd.drupal-dialog, application/vnd.drupal-modal.'; $this->assertIdentical($expected_message, $response); } diff --git a/core/modules/rest/src/Tests/ResourceTest.php b/core/modules/rest/src/Tests/ResourceTest.php index 729af5c..35e1807 100644 --- a/core/modules/rest/src/Tests/ResourceTest.php +++ b/core/modules/rest/src/Tests/ResourceTest.php @@ -56,7 +56,11 @@ public function testFormats() { // Verify that accessing the resource returns 401. $response = $this->httpRequest($this->entity->getSystemPath(), 'GET', NULL, $this->defaultMimeType); - $this->assertResponse('404', 'HTTP response code is 404 when the resource does not define formats.'); + // AcceptHeaderMatcher considers the canonical, non-REST route a match, but + // a lower quality one: no format restrictions means there's always a match, + // and hence when there is no matching REST route, the non-REST route is + // used, but it can't render into application/hal+json, so it returns a 406. + $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.'); $this->curlClose(); } @@ -81,7 +85,11 @@ public function testAuthentication() { // Verify that accessing the resource returns 401. $response = $this->httpRequest($this->entity->getSystemPath(), 'GET', NULL, $this->defaultMimeType); - $this->assertResponse('404', 'HTTP response code is 404 when the resource does not define authentication.'); + // AcceptHeaderMatcher considers the canonical, non-REST route a match, but + // a lower quality one: no format restrictions means there's always a match, + // and hence when there is no matching REST route, the non-REST route is + // used, but it can't render into application/hal+json, so it returns a 406. + $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.'); $this->curlClose(); } diff --git a/core/modules/system/src/Tests/Common/PageRenderTest.php b/core/modules/system/src/Tests/Common/PageRenderTest.php index b270170..ad8a087 100644 --- a/core/modules/system/src/Tests/Common/PageRenderTest.php +++ b/core/modules/system/src/Tests/Common/PageRenderTest.php @@ -7,7 +7,7 @@ namespace Drupal\system\Tests\Common; -use Drupal\Core\Controller\HtmlController; +use Drupal\Core\Render\MainContent\HtmlRenderer; use Drupal\simpletest\KernelTestBase; /** @@ -62,14 +62,14 @@ function testHookPageAttachmentsAlter() { function assertPageRenderHookExceptions($module, $hook) { // Assert a valid hook implementation doesn't trigger an exception. $page = []; - HtmlController::invokePageAttachmentHooks($page); + HtmlRenderer::invokePageAttachmentHooks($page); // Assert an invalid hook implementation doesn't trigger an exception. \Drupal::state()->set($module . '.' . $hook . '.descendant_attached', TRUE); $assertion = $hook . '() implementation that sets #attached on a descendant triggers an exception'; $page = []; try { - HtmlController::invokePageAttachmentHooks($page); + HtmlRenderer::invokePageAttachmentHooks($page); $this->error($assertion); } catch (\LogicException $e) { @@ -83,7 +83,7 @@ function assertPageRenderHookExceptions($module, $hook) { $assertion = $hook . '() implementation that sets a child render array triggers an exception'; $page = []; try { - HtmlController::invokePageAttachmentHooks($page); + HtmlRenderer::invokePageAttachmentHooks($page); $this->error($assertion); } catch (\LogicException $e) { diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php index 21b3ac6..946504d 100644 --- a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php @@ -16,7 +16,7 @@ use Drupal\Core\Form\FormStateInterface; /** - * Dummy form for testing DialogController with _form routes. + * Dummy form for testing DialogRenderer with _form routes. */ class AjaxTestDialogForm extends FormBase { diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestForm.php index 4155b32..677532f 100644 --- a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestForm.php +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestForm.php @@ -11,7 +11,7 @@ use Drupal\Core\Form\FormStateInterface; /** - * Dummy form for testing DialogController with _form routes. + * Dummy form for testing DialogRenderer with _form routes. */ class AjaxTestForm implements FormInterface { diff --git a/core/modules/system/theme.api.php b/core/modules/system/theme.api.php index 775e7b6..37b769e 100644 --- a/core/modules/system/theme.api.php +++ b/core/modules/system/theme.api.php @@ -307,55 +307,68 @@ * First, you need to know the general routing concepts: please read * @ref sec_controller first. * - * A _controller route expects the controller to return a Response. - * - * A non-_controller (typically _content) route expects the controller to return - * the "main content", as a render array. That main content may be requested and - * rendered in multiple ways: it can be rendered in a certain format (HTML, JSON - * …) and/or in a certain decorated manner (e.g. with blocks around the main - * content). - * Therefore the first step is to negotiate a format - * (\Drupal\Core\EventSubscriber\ContentControllerSubscriber::onRequestDeriveFormat()) - * which will determine which "main content controller" (which implementation of - * \Drupal\Core\Controller\MainContentControllerInterface) to use. The selected - * main content controller will be set on the request as the _controller to use - * (just like a "regular" _controller route attribute). - * - * Having negotiated a _controller, we now go to the three stages of main - * content controllers: - * 1. getMainContent(): get the main content from the (_content) sub-controller, - * this must always be a render array - * 2. prepareContent(): apply any preparations/transformations - * 3. renderContentIntoResponse(): turn the content render array into a response - * - * These same steps are applied regardless of which main content controller is - * selected: it's the same for the AJAX, HTML, Drupal Dialog and Drupal Modal - * Dialog controllers. - * - * Specific main content controllers may of course implement additional - * flexibility if they want to. The default HTML controller does this, for - * example. And since rendering HTML pages is the most common use for Drupal, - * this will cover that also. - * - * \Drupal\Core\Controller\HtmlController::prepareContent() looks at the render - * array it receives. If it's already #type 'page', then most of the work it - * should do is already done. After all, if a #type 'page' is indicated to be - * the main content, then that implies no decorations should be applied, since - * it already represents the final for the HTML document. - * If it's not yet #type 'page', however, then we need to build that still. The - * SystemEvents::SELECT_PAGE_DISPLAY_VARIANT event is dispatched, to select a - * page display variant. By default, SimplePageVariant is used, which doesn't do - * any decorating. But when Block module is enabled, BlockPageVariant is used, - * which allows the site builder to place blocks in any of the page regions, and - * hence "decorate" the main content. - * The outcome of \Drupal\Core\Controller\HtmlController::prepareContent() is - * always a #type 'page' render array. This is considered the "actual" content - * for HTML responses. - * - * \Drupal\Core\Controller\HtmlController::renderContentIntoResponse() — the - * third and final step — then wraps the #type 'page' (which represents - * page.html.twig) in a #type 'html' render array (which represents - * html.html.twig), to then render the entire HTML document. + * Any route that returns the "main content" as a render array automatically has + * the ability to be requested in multiple ways: it can be rendered in a certain + * format (HTML, JSON …) and/or in a certain decorated manner (e.g. with blocks + * around the main content). + * + * After the controller returned a render array, the @code VIEW @endcode event + * (\Symfony\Component\HttpKernel\KernelEvents::VIEW) will be triggered, because + * the controller result is not a Response, but a render array. + * + * \Drupal\Core\EventSubscriber\MainContentViewSubscriber is subscribed to the + * @code VIEW @endcode event. It checks whether the controller result is an + * array, and if so, guarantees to generate a Response. + * + * Next, it checks whether the negotiated request format is supported. Any + * format for which a main content renderer service exists (an implementation of + * \Drupal\Core\Render\MainContent\MainContentRendererInterface) is supported. + * + * If the negotiated request format is not supported, a 406 response is + * generated, including a helpful message that lists the supported formats (as + * per RFC 2616, section 10.4.7). + * + * Otherwise, when the negotiated request format is supported, the corresponding + * main content renderer service is initialized. A response is generated by + * calling \Drupal\Core\Render\MainContent\MainContentRendererInterface::renderResponse() + * on the service. That's it! + * + * Each main content renderer service can choose how to implement its + * renderResponse() method. It may of course choose to add protected helper + * methods to provide more structure, if it's a complex main content renderer. + * + * The above is the general flow. But let's take a look at the HTML main content + * renderer (\Drupal\Core\Render\MainContent\HtmlRenderer), because that will be + * used most often. + * + * \Drupal\Core\Render\MainContent\HtmlRenderer::renderResponse() first calls a + * helper method, @code prepare() @endcode, which takes the main content render + * array and returns a #type 'page' render array. A #type 'page' render array + * represents the final for the HTML document (page.html.twig). The + * remaining task for @code renderResponse() @endcode is to wrap the #type + * 'page' render array in a #type 'html' render array, which then represents the + * entire HTML document (html.html.twig). + * Hence the steps are: + * 1. \Drupal\Core\Render\MainContent\HtmlRenderer::prepare() takes the main + * content render array; if it already is #type 'page', then most of the work + * it must do is already done. In the other case, we need to build that #type + * 'page' render array still. The SystemEvents::SELECT_PAGE_DISPLAY_VARIANT + * event is dispatched, to select a page display variant. By default, + * \Drupal\system\Plugin\DisplayVariant\SimplePageVariant is used, which + * doesn't apply any decorations. But, when Block module is enabled, + * \Drupal\block\Plugin\DisplayVariant\BlockPageVariant is used, which allows + * the site builder to place blocks in any of the page regions, and hence + * "decorate" the main content. + * 2. \Drupal\Core\Render\MainContent\HtmlRenderer::prepare() now is guaranteed + * to be working on a #type 'page' render array. hook_page_attachments() and + * hook_page_attachments_alter() are invoked. + * 3. \Drupal\Core\Render\MainContent\HtmlRenderer::renderResponse() uses the + * #type 'page' render array returned by the previous step and wraps it in + * #type 'html'. hook_page_top() and hook_page_bottom() are invoked. + * 4. drupal_render() is called on the #type 'html' render array, which uses + * the html.html.twig template and the return value is a HTML document as a + * string. + * 5. This string of HTML is returned as the Response. * * For HTML pages to be rendered in limited environments, such as when you are * installing or updating Drupal, or when you put it in maintenance mode, or diff --git a/core/tests/Drupal/Tests/Core/Controller/AjaxControllerTest.php b/core/tests/Drupal/Tests/Core/Controller/AjaxRendererTest.php similarity index 45% rename from core/tests/Drupal/Tests/Core/Controller/AjaxControllerTest.php rename to core/tests/Drupal/Tests/Core/Controller/AjaxRendererTest.php index f131166..c0651df 100644 --- a/core/tests/Drupal/Tests/Core/Controller/AjaxControllerTest.php +++ b/core/tests/Drupal/Tests/Core/Controller/AjaxRendererTest.php @@ -2,38 +2,32 @@ /** * @file - * Contains \Drupal\Tests\Core\Controller\AjaxControllerTest. + * Contains \Drupal\Tests\Core\Controller\AjaxRendererTest. */ namespace Drupal\Tests\Core\Controller; -use Drupal\Core\Ajax\AjaxResponse; -use Drupal\Core\Controller\AjaxController; +use Drupal\Core\Render\MainContent\AjaxRenderer; use Drupal\Tests\UnitTestCase; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; /** - * @coversDefaultClass \Drupal\Core\Controller\AjaxControllerTest + * @coversDefaultClass \Drupal\Core\Render\MainContent\AjaxRenderer * @group Ajax */ -class AjaxControllerTest extends UnitTestCase { +class AjaxRendererTest extends UnitTestCase { /** * The tested ajax controller. * - * @var \Drupal\Tests\Core\Controller\TestAjaxController + * @var \Drupal\Core\Render\MainContent\AjaxRenderer */ - protected $ajaxController; + protected $ajaxRenderer; /** * {@inheritdoc} */ protected function setUp() { - $controller_resolver = $this->getMock('Drupal\Core\Controller\ControllerResolverInterface'); - $controller_resolver->expects($this->any()) - ->method('getArguments') - ->willReturn([]); $element_info_manager = $this->getMock('Drupal\Core\Render\ElementInfoManagerInterface'); $element_info_manager->expects($this->any()) ->method('getInfo') @@ -43,18 +37,20 @@ protected function setUp() { '#commands' => array(), '#error' => NULL, ]); - $this->ajaxController = new TestAjaxController($controller_resolver, $element_info_manager); + $this->ajaxRenderer = new TestAjaxRenderer($element_info_manager); } /** * Tests the renderMainContent method. * - * @covers \Drupal\Core\Controller\AjaxController::renderContentIntoResponse + * @covers \Drupal\Core\Render\MainContent\AjaxRenderer::renderResponse */ public function testRenderWithFragmentObject() { $main_content = ['#markup' => 'example content']; + $request = new Request(); + $route_match = $this->getMock('Drupal\Core\Routing\RouteMatchInterface'); /** @var \Drupal\Core\Ajax\AjaxResponse $result */ - $result = $this->ajaxController->renderContentIntoResponse($main_content, '', []); + $result = $this->ajaxRenderer->renderResponse($main_content, $request, $route_match); $this->assertInstanceOf('Drupal\Core\Ajax\AjaxResponse', $result); @@ -66,39 +62,9 @@ public function testRenderWithFragmentObject() { $this->assertEquals('status_messages', $commands[1]['data']); } - /** - * Tests the handle method with a Json response object. - * - * @covers \Drupal\Core\Controller\AjaxController::handle - */ - public function testRenderWithResponseObject() { - $json_response = new JsonResponse(array('foo' => 'bar')); - $request = new Request(); - $route_match = $this->getMock('Drupal\Core\Routing\RouteMatchInterface'); - $_content = function() use ($json_response) { - return $json_response; - }; - $this->assertSame($json_response, $this->ajaxController->handle($request, $route_match, $_content)); - } - - /** - * Tests the handle method with an Ajax response object. - * - * @covers \Drupal\Core\Controller\AjaxController::handle - */ - public function testRenderWithAjaxResponseObject() { - $ajax_response = new AjaxResponse(array('foo' => 'bar')); - $request = new Request(); - $route_match = $this->getMock('Drupal\Core\Routing\RouteMatchInterface'); - $_content = function() use ($ajax_response) { - return $ajax_response; - }; - $this->assertSame($ajax_response, $this->ajaxController->handle($request, $route_match, $_content)); - } - } -class TestAjaxController extends AjaxController { +class TestAjaxRenderer extends AjaxRenderer { /** * {@inheritdoc}