Ruby on Rails has been around a long time but nevertheless continues to evolve, and the most surprising thing is that DHH is still at it — imagine such commitment to the project after 11 years!
DHH has inspired a lot of new features in Rails 5. By the way, if you want to start contributing to Rails, you can begin doing so by tracking his issues. They provide many opportunities while being very broad in complexity. For example, most recent basic ones are Enumerable#without
and Enumerable#pluck
.
In this article, I’ll talk about my work on a more complex idea of DHH—Rendering views outside of actions.
It needs to have a mention that DHH is not an abstract theoretician; all his ideas come from real-world needs and the continuous desire to improve the state of the framework.
Specifically, this feature was born out of a wish to render Rails views from background tasks and websocket workers.
At Evil Martians, we had a couple of use-cases as well: we had a need for async rendering of slow reports, and we needed to generate PDF documents as email attachments as well.
Rails 5 implements this feature with a render
method, which is now available as a controller class method.
# render template
ApplicationController.render 'templates/name'
# render action
FooController.render :index
# render file
ApplicationController.render file: 'path'
# render inline
ApplicationController.render inline: 'erb content'
It supports the same arguments as an instance method but returns a rendered text of a template.
Templates usually use instance variables as a way to access data from an action. To render such templates without an action, a new option is now available: assigns
. It enables you to set instance variables in a template explicitly.
ApplicationController.render(
assigns: { foo: 'bar' },
inline: '<%= @foo %>'
) # => 'bar'
That should be sufficient for the most simple cases. However, for more complex situations we need to introduce something called a Request Environment.
Request Environment
All templates are rendered in a certain context with supplementary data.
Let’s say we use the users_url
helper. How does this helper know the HTTP hostname and schema from if default_url_options
is empty?
It has access to the hostname and schema via a request environment. If you are unfamiliar with the term, no worries — a short introduction follows.
All Rails requests have the environment context in which they perform. This context is a simple Ruby hash containing data associated with the request. Let me give you an example of the basic request environment.
# a get request to https://evilmartians.com/feedback?source=blog
# will have a corresponding environment:
{
"REQUEST_METHOD" => "GET",
"HTTP_HOST" => "evilmartians.com",
"SERVER_PORT" => "443",
"PATH_INFO" => "/feedback",
"QUERY_STRING" => "source=blog",
"HTTPS" => "on"
#...
}
Each request corresponds to a separate environment.
You can access the environment as request.env
in a Rails action. This feature is not unique to Rails: most Ruby web frameworks are built on top of Rack specification and request environment is a part of the specification.
Libraries and frameworks use Rack environment as a place to store request related data — Ruby on Rails keeps session data and cookies in there as well.
But let’s get back to our users_url
example. HTTP host and schema are fetched from a default renderer environment:
ApplicationController.renderer.defaults # =>
{
http_host: 'example.org',
https: false
# ...
}
ApplicationController.render inline: '<%= users_url %>'
# => 'http://example.org/users'
To change the environment in which template is rendered, we need to change renderer.defaults
or initialize a new renderer in an explicit way.
Let’s configure our URL helpers to use the HTTPS scheme and evilmartians.com host with a new renderer:
renderer = ApplicationController.renderer.new(
http_host: 'evilmartians.com',
https: true
)
renderer.render inline: '<%= users_url %>'
# => 'https://evilmartians.com/users'
This example shows that since templates use helpers, it may be necessary to adjust a request environment even though we render outside of a request-response cycle.
Pitfall #1
Remember me mentioning that some libraries use the environment to store data? Well, helpers from these gems might depend on the environment to contain data populated by middlewares.
For example, if you use Devise:
ApplicationController.render inline:
'<% unless user_signed_in? %>guest<% end %>'
# =>
# ActionView::Template::Error:
# undefined method `authenticate' for nil:NilClass
All Devise helpers depend on a properly initialized warden
key in the environment. However, it is missing here since no middleware has been executed.
Ideally in situations like these, helpers should use a fallback for a missing value automatically. Hopefully, more gem authors would be aware of situations like these and improve their code if relevant issues are created.
As a workaround, a missing key can be provided manually. However, it is not always easy to find out what key is missing and what value should an environment hold.
The task becomes much easier if the environment could be inspected after a middleware stage. A simple hack to achieve this would be:
mock_env = Rack::MockRequest.env_for('/')
catch(:env) do
Rails.application.middleware.build(lambda { |env|
throw :env, env
}).call mock_env
end
# => { 'warden' => Warden::Proxy:70135409248120, ... }
In this example, I’ve highlighted the key that we were after to fix Devise rendering.
Hopefully, all gems could provide environment fallbacks, and developers would not spend time fixing helper failures by deducing what’s missing from the environment.
Pitfall #2
There is one more example of a possible problem when rendering views. url_for
is sometimes used to rewrite current URL with additional parameters, but it does not work out of the box:
# routes.rb: get '/catalog' => 'items#all'
ApplicationController.render(
inline: '<%= url_for page: 2 %>'
)
# => ActionView::Template::Error: No route matches {:action=>"index"}
This exception happens since url_for
searches for a current route based on params. No params are specified here (except a default index
fallback for action), and the only route we do have is a catalog page that does not match such params.
Therefore, to fix our problem, it should be enough to specify the appropriate params. It will be easy once we remember that route parameters are stored in environment under action_dispatch.request.path_parameters
key:
# routes.rb: get '/catalog' => 'items#all'
ApplicationController.renderer.new(
'action_dispatch.request.path_parameters' => {
controller: 'items',
action: 'all'
}
).render inline: '<%= url_for page: 2 %>'
# => '/catalog?page=2'
Conclusion
A new API to render outside of an action in Rails 5 won’t take long to learn since it is very similar to usual render
calls.
The only difference is that templates would use a virtual environment instead of a real one. Using a virtual environment leads to a couple of gotchas that we’ve already discussed. Associated problems will disappear in future.
By the way, I’ve backported this feature to Rails 4. Just add the backport_new_renderer
gem to your project Gemfile and you’ll have the same API available.