I found myself with an odd scenario the other day where I needed to render a rails partial (using HAML with some templates, styles, and a layout) from within an API controller. Rails API controllers by design do not have the capability to render anything other than JSON in the rails framework, so I started down a rabbit hole of how I could get this to work.
Context
The use case for needing to do this is previewing what HTML will be generated before sending custom emails via the API. Straight forward enough it would seem, to use the render_to_string
method used elsewhere in the application. However, since this method ties in directly to the inherited controller context, and ApplicationController::API
does not have the required renderers, this does not work as intended. What's worse, is that there are no errors, and only a blank string is rendered. Annoyingly, the tests for this endpoint only checked that the returned value was a string and the response was a 200; Rookie mistake.
Attempt #1: ApplicationController
As noted above, simply using render_to_string
wasn't going to cut it here. I had to get creative and start finding another solution. Googling didn't help much as no-one seemed to have this problem, and those that did, just changed to inherit from ActionController::Base
(the default rails controller) which solves all of the problems.
This wasn't an option for this application as all of the API specific context, such as authentication, was on the base controller which inherited from ActionController::API
. I also didn't want to pollute the API namespace with non API controllers as they are quite heavy.
Attempt #2: Manual modules
My next stop was to start digging around the rails codebase. What was so special about the ActionController::Base
that allowed it to render strings but the API controller couldn't. It turns out, there are a huge amount of modules included in the ActionController::Base
and a lot of them had names like Rendering
View
Layout
etc so this made sense why these controllers could work with render*
methods as expected.
I started manually importing some of these modules, but the more I added, the more odd errors I started to receive. By the time I reached about 8 modules I started to think maybe this wasn't the best solution and decided to abandon this approach.
Attempt #3: New controller instance
After a little more digging I started looking at the actual render methods themselves, and the context they require. More specifically I looked at the tests, since they had to somehow build the context to test these methods.
Voila! Building a new controller then calling render_to_string
did exactly what I expected it to. I also added a test for good measure to verify the content was actually the correct content was returned, rather than just that is was a string with no errors. Lesson learned here, don't be lazy with writing tests.
Here is the code I ended up using:
class MyController < ActionController::API
def preview
body = ApplicationController.new.render_to_string(inline: model.body, layout: 'mail')
end
end
It's worth noting that this application did in fact have a base ApplicationController
which inherited from ActionController::Base
, so it had all of the necessary methods for rendering html inline using ActionView
.
Not so fast!
A few days later I needed to do this again. But this time the scenario was a lot more complex and the above solution didn't hold up. This endpoint was a hybrid to be embedded into a react template without rewriting a large portion of the application. It's a bit hacky, but saved potentially weeks worth of work.
Just rendering the email templates didn't actually require any controller context. It has no auth for example, or a workspace context. When using my solution with a full rails controller action, things fell apart. It no longer had access to headers, contexts and modules that had been configured for the current request flow. This as super annoying, so I went rummaging in source code again.
Attempt #4
The issue was due to ApplicationController.new
not actually having any context by just being initialized with an empty context. Fine for inline templates, not so great for a view template designed for an authed user request.
Deep in the rails source code I found out how these controller instances are built and used for rendering, and I cam across this:
# https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/renderer.rb#L48
class ActionController
class Renderer
def self.for(controller, env = {}, defaults = DEFAULTS.dup)
new(controller, env, defaults)
end
end
end
This seemed to be exactly what I was looking for! Since this logic had been plucked from the standard rails part of the application, it had a specific controller it needed context from. Piecing together the Renderer.for
function I was able to determine this would use a controller class to initiate the context required and then the render
method was essentially render_to_string
.
The final code I came up with (and replaced the original email implementation too) was as follows:
body = ActionController::Renderer.for(ApplicationController).render(inline: model.body, layout: 'customer')
Conclusion
Although rails is an incredible framework with over a decade of production use and fine tuning from 10s of thousands of developers, there are still some odd edge cases that seem trivial at first but still require some deep diving into the codebase to figure out exactly how to do something. Don't be afraid to jump into the codebase, especially since it's all just ruby under the hood. All of that "magic" rails provides is just hiding in there waiting to be unravelled.