A recent challenge that I faced on a project was to generate the HTML of a full Drupal page, from within a Drupal request. The project called for a template to be rendered within the structure of a Drupal theme and also to include the styles associated with that theme.
At first, this doesn't seem like a big problem, but once I started trying to figure things out it became much more complex than I was expecting.
The problem is that you can't just render a page using the 'html' and 'page' templates as there is a lot of context surrounding those templates. Without this context in place Drupal produces a page of markup that contains no styles or blocks. The context is how Drupal knows what theme to load, what libraries to add to the page, what preprocess hooks to call, what menu items to generate, and so on.
I found a couple of different solutions to this problem that work quite well.
In both of these situations I will generate the page render and then send it back to the user using a new Response object from a controller. This essentially replaces the response from the Drupal controller with our custom markup.
Solution 1: Render A Page Using A Get Request
Since we are trying to render a page it sort of makes sense that we ask Drupal to render that full page for us through a sub-request. This means making a secondary request using the Guzzle HTTP Client to the page we want to render and then returning the result of that request in the Response object.
This is perhaps the simplest approach as we just need to figure out the page we want to go to and then make the request to that page.
Here is the action method for a normal Drupal controller where we are rendering node 123 in the full context of a page response. I've left out a bit of boilerplate code here, but you'll want to inject the 'http_client' service into your controller and store it in the objects httpClient property.
public function returnGetPage() {
// Generate the Url object for the page we want to get.
$url = new Url('entity.node.canonical', ['node' => 123]);
// Make the call to the page.
$response = $this->httpClient->request('GET', $url->setAbsolute()->toString());
// Pull the content out of the response.
$page = $response->getBody()->getContents();
// Return the content.
return new Response($page);
}
As a quick side note, if you want to make the request and also ignore any SSL certificate errors then you can pass in an options array like this.
$options = [
\GuzzleHttp\RequestOptions::VERIFY => FALSE,
];
$response = $this->httpClient->request('GET', $url->setAbsolute()->toString(), $options);
This options array is useful if you are writing this code locally and want to be able to test things. You'll might not have a valid certificate and so you'll need this for the request to complete.
There are a couple of big limitations to this approach of using a sub-request to access a Drupal page. If the page you are trying to access is behind any authentication then you'll need to inject that authentication into the request or you'll just get back a 403 HTTP response. You can do this in the same options array example I added above.
Also, since we are essentially creating another web request here then there is a danger that we will double the page request times and potentially the server load. Since the request to the Drupal site will then make another request to Drupal the response times for this page request might be slow.
If you do go down this route then I strongly suggest you add in some caching to prevent the site slowing down significantly.
Ultimately, the response we get back from the controller action is a fully rendered page of content. Since we didn't pass the current session to the sub-request, and we essentially returned the response early, the rendered page looks as though we have accessed it through an anonymous session.
Solution 2: Render A Page Using Drupal's Rendering Pipeline
After I created the code for solution 1 I started to look into other ways of doing this as that approach has a number of limitations. This lead me to find a couple of Drupal services that I hadn't used before, but I was able to create a full page of content using Drupal services and templates.
There is quite a lot going on with this approach, so I'll split the code up into smaller chunks and go through them bit by bit.
The first things we need to do is render the content of the page. Since I wanted to render the node 123 this meant loading that node and passing it through the rendering engine to generate the page content. You might have seen this code elsewhere since it's a pretty common question.
// Render the node.
$nid = 123;
$entityType = 'node';
$viewMode = 'default';
$storage = \Drupal::entityTypeManager()->getStorage($entityType);
$node = $storage->load($nid);
$viewBuilder = \Drupal::entityTypeManager()->getViewBuilder($entityType);
$build = $viewBuilder->view($node, $viewMode);
After running this code the $build variable above now contains the render array for the node.
Since we want to render the page based on the context of the node that we loaded, the next step is to tell Drupal that this is what we want to do. This involves loading in the current request object and setting the node as an attribute of the request. This will be used upstream to pull in the blocks and other things that go to make up a Drupal page.
// Get the current request object.
$request = \Drupal::request();
// Inject the node object into the request.
$request->attributes->set('node', $node);
Note that you might need to remove attributes from the request if your pages response contains things not connected to nodes. Since we are in a simple controller and using a hard coded node ID there won't be much in the response object.
Similarly to setting the correct request we also need to generate a RouteMatch object that contains information about the route we are accessing. This is used in by Drupal to inform the rendering process about the current context of the page. The RouteMatch is generated by getting the Url object out of the Node object we loaded above and using that to generate a new RouteMatch object.
// Extract the url from the node object.
$url = $node->toUrl();
// Generate a RouteMatch .
/** @var \Drupal\Core\Routing\RouteProvider $route */
$route = \Drupal::service('router.route_provider')->getRouteByName($url->getRouteName());
$routeMatch = new RouteMatch($url->getRouteName(), $route, $url->getRouteParameters());
With all of that in hand we can then go on to render the page in full.
This involves pulling in an instance of the 'main_content_renderer.html' service and using the renderResponse() method to render the render array we generated before. The $build render array, the Request object and the RouteMatch object that we just created are also passed into this method. The response of this method is a Response object that contains all of the markup information we need as well as some other bits of pieces.
// Render the page.
/** @var \Drupal\Core\Render\MainContent\HtmlRenderer $renderer */
$renderer = \Drupal::service('main_content_renderer.html');
$response = $renderer->renderResponse($build, $request, $routeMatch);
This isn't the end of the story just yet though. The response we get back from this method is missing all of the styles and scripts that are normally injected into a Drupal page.
The generated markup at this point will contain placeholders for that content that come from the html.html.twig template. These placeholders from that template look like this and haven't been replaced yet.
<head>
<head-placeholder token="{{ placeholder_token }}">
<title>{{ head_title|safe_join(' | ') }}</title>
<css-placeholder token="{{ placeholder_token }}">
<js-placeholder token="{{ placeholder_token }}">
</head>
To inject the needed libraries for the page we need to use the 'html_response.attachments_processor' service. This service contains a method called processAttachments() that takes a response object and will convert the placeholders into the correct style and script tags.
// Finish the render.
/** @var \Drupal\Core\Render\HtmlResponseAttachmentsProcessor $processor */
$processor = \Drupal::service('html_response.attachments_processor');
$response = $processor->processAttachments($response);
The response object returned here now contains all of the markup of the page as if it was generated through a normal web request. As this response object contains cache metadata that will cause Drupal to throw an error we therefore need to grab the content from the response and issue our own response object. These are the last lines of the method and the returned response will contain an entire rendered page from Drupal.
// Grab the content from the response.
$content = $response->getContent();
// Return the content.
return new Response($content);
The good thing about this method (despite some of the complexity of the components) is that any it can be done with pages that would otherwise be hidden from view. This gives us a neat mechanism of showing users a page of content without actually giving them direct access to that content. The only thing we need to do is to create a render array from the entity or template so that we can pass this information to the main_content_renderer.html service.
Putting all this together, we have a controller that looks a little like the following. Note that I have replaced all of the statically called services with dependency injected services, which is the best practice approach in Drupal.
<?php
namespace Drupal\mymodule\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Routing\RouteMatch;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Render\MainContent\MainContentRendererInterface;
use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
class MyModuleController extends ControllerBase {
/**
* The Entity Type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The route provider interface.
*
* @var \Drupal\Core\Routing\RouteProviderInterface
*/
protected $routeProvider;
/**
* The request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* The main content renderer service.
*
* @var \Drupal\Core\Render\MainContent\MainContentRendererInterface
*/
protected $mainContentRenderer;
/**
* The attachment processor service.
*
* @var \Drupal\Core\Render\AttachmentsResponseProcessorInterface
*/
protected $attachmentsProcessor;
/**
* AuditController constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The Entity Type manager.
* @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider interface.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The current request object.
* @param \Drupal\Core\Render\MainContent\MainContentRendererInterface $main_content_renderer
* The main content renderer service.
* @param \Drupal\Core\Render\AttachmentsResponseProcessorInterface $attachments_processor
* The attachment processor service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, RouteProviderInterface $route_provider, RequestStack $request_stack, MainContentRendererInterface $main_content_renderer, AttachmentsResponseProcessorInterface $attachments_processor) {
$this->entityTypeManager = $entity_type_manager;
$this->routeProvider = $route_provider;
$this->request = $request_stack->getCurrentRequest();
$this->mainContentRenderer = $main_content_renderer;
$this->attachmentsProcessor = $attachments_processor;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('router.route_provider'),
$container->get('request_stack'),
$container->get('main_content_renderer.html'),
$container->get('html_response.attachments_processor')
);
}
public function returnRenderedPage() {
// Render the node.
$nid = 123;
$entityType = 'node';
$viewMode = 'default';
$storage = $this->entityTypeManager->getStorage($entityType);
$node = $storage->load($nid);
$viewBuilder = $this->entityTypeManager->getViewBuilder($entityType);
$build = $viewBuilder->view($node, $viewMode);
// Inject the node object into the request.
$this->request->attributes->set('node', $node);
// Extract the url from the node object.
$url = $node->toUrl();
// Generate a RouteMatch object.
$route = $this->routeProvider->getRouteByName($url->getRouteName());
$routeMatch = new RouteMatch($url->getRouteName(), $route, $url->getRouteParameters());
// Render the page.
$response = $this->mainContentRenderer->renderResponse($build, $this->request, $routeMatch);
// Finish the render.
$response = $this->attachmentsProcessor->processAttachments($response);
// Grab the content from the response.
$content = $response->getContent();
// Return the content.
return new Response($content);
}
}
The fact that we are leveraging Drupal's page rendering pipeline to generate the HTML is useful as it means we don't need to worry about making sure blocks are placed onto the page. Drupal will do that for us based on the context we generate.
Perhaps the most complex part of this is in figuring out how to render the thing you want to render and making sure that the Request object contains that information. Once you have that in place everything else follows in the same way.
These techniques have a number of uses outside of just rendering a page of content and sending that to the user. For example, instead of sending the content back to the user you can instead save the content to a file or perhaps send the response to a reverse proxy system to generate a cache.
Working all this out proved quite a challenge, and there seemed to be very little information on how to do it on the internet. I hope this article has proven useful to you. If you are stuck with similar problems then please get in touch and see if we can help you overcome them.
Comments
Philip, it looks like you want to do all the work yourself, which is what Drupal does.
Why not just redirect the redirect to the address you need?
Submitted by darkdim on Mon, 06/13/2022 - 09:56
PermalinkHi darkdim,
In the situation I had I couldn't issue a redirect. I instead needed to generate the page of HTML from Drupal and send it upstream. When searching for this I found a few posts from other people asking for the same thing, but no one really had a good solution.
Otherwise, I completely agree, creating a redirect is probably the best course of action here.
Submitted by giHlZp8M8D on Mon, 06/13/2022 - 10:50
PermalinkInstead of using Guzzle, you can probably use a subrequest:
This way you avoid an unnecessary HTTP connection.
Submitted by Rudloff on Fri, 06/17/2022 - 16:04
PermalinkI agree, a sub-request is the best solution. For Drupal you need some additional steps, though, in preparation and clean-up:
https://drupal.stackexchange.com/questions/303396/making-an-http-subreq…
Submitted by JackU on Wed, 06/29/2022 - 13:55
PermalinkGreat article!
While try your code, the breadcrumbs seems not correct. For example,
The /static-generaoter call MyModuleController, and the generated page breadcrumbs display the breadcrumb of /static-generator.
Have you face the same problem or have you found a solution for this?
Thanks again!
Submitted by Terry on Sat, 08/06/2022 - 14:43
PermalinkHi Terry,
Thanks for the comment!
I did have a couple of problems like that, but I was able to work around them by ensuring that the correct context was in place.
I've looked at the breadcrumb code and it appears to need the route of the current page, which means the code above should provide enough context for the breadcrumb module to do what it needs.
Maybe you aren't setting this context correctly in your code?
Submitted by giHlZp8M8D on Sun, 08/07/2022 - 19:59
PermalinkI just have no idea on how to setthe context for the breadcrumbs. Have you found a way to resolved it?
Also a new problem: if the multilangual is setup, the translation block links looks not work correctly too:(
Looking for your share:)
Submitted by Terry on Mon, 08/08/2022 - 19:02
PermalinkAdd new comment