Writing a Rails Metal app can make you realize just how spoiled we’ve become with all the convenience that comes with Rails. Without the controller and view helpers, it can become a painful experience. Here’s a guide to help make it a better experience.
For this guide, we’re writing a Widget Refresher Metal app. Supposedly, the widget page of our Rails application gets called too much, and so we want to take advantage of Metal. Under our project’s app/metal folder, we create refresher.rb:
class Refresher < Rails::Rack::Metal def self.call(env) refresher = RefresherApp.new refresher.call(env) end end class RefresherApp def call(env) # refresh widget path: /widgets/:id/refresh if env["PATH_INFO"] =~ /^\/widgets\/(\d+)\/refresh/ widget_id = $1 prepare(env, widget_id) refresh else [404, { "Content-Type" => "text/html" }, "Not Found"] end end # to setup the environment def prepare(env, widget_id) ... end # the heart of our Metal app def refresh ... end end
I like to create a separate class RefresherApp instead of just writing all of it inside the Refresher class (the one that extends from Rails::Rack::Metal). When your Metal app becomes more than just a trivial hello world app, you’ll be needing a bunch of methods calling each other. Since the call method in the Metal app is a class method, putting all the code in one class will require all these methods to be class methods as well. And I think that looks ugly. Feel free to stick it all in one class if you want. If you do, you can change the context to self so you don’t have to keep on defining each method as self.method:
class Refresher < Rails::Rack::Metal # the methods in here are class methods class << self def call(env) ... end def method ... end end end
For the rest of the guide, we’re using my approach. Also, note that when developing a Metal app, you need to keep on restarting your server for your code changes to take effect.
Request and Session
To access the request and the parameters in it, you can use this code:
request = Rack::Request.new(env) params = request.params params['mykey'] # String keys, so not params[:mykey]
As you can see, the keys will be of class String, not Symbol. Now for the session, you can get it from the environment:
session = env['rack.session']
We can move all these code into our prepare method. In addition, we can set the params[:id] (using a Symbol if you want), so that in our main refresh method, it would be like in a Rails controller. With the session, we can get the current user. We can also define other methods to make things more like writing code for a Rails controller. This is how it looks like:
attr_reader :request, :session, :current_user def params @request.params end def logged_in? !!current_user end def prepare(env, widget_id) @request = Rack::Request.new(env) params[:id] = widget_id @session = env["rack.session"] @current_user = session[:user_id] ? User.find(session[:user_id]) : false end
With these out of the way, we go into writing the code for the main method called refresh.
refresh and ActiveRecord
ActiveRecord works out of the box, no setup needed. Cool! Let’s say we just need to return the status of widget to the client side:
def refresh @widget = Widget.find(params[:id]) return [200, { "Content-Type" => "text/html" }, @widget.status] end
We can also send javascript code, or other content types back to our client. Just make sure to set your content type properly. Let’s also add some simple checking if the user is logged in:
def refresh @widget = Widget.find(params[:id]) if logged_in? return [200, { "Content-Type" => "text/javascript" }, "Element.update('status', '#{@widget.status}');"] else return [200, { "Content-Type" => "text/javascript" }, "Element.update('message', 'Must be logged in for widget status to refresh');"] end end
When returning more complex javascript however, it’s probably better to escape the newlines and the quotes or we’ll get parsing errors on the browser side. Rails provides a helper method called escape_javascript, but a Metal app doesn’t have access to helpers by default. So…
View Helpers
To use helpers in your Metal app, just include the modules you need:
include ActionView::Helpers::JavascriptHelper # so escape_javascript works include WidgetsHelper # for example
I prefer to avoid including too much of these helpers though.
Request Forgery Protection
If the request is not a GET request, we may need to verify the authenticity token. Here’s one way to do it:
def refresh # before everything else return redirect_to_widgets_response unless verified_request? # everything else ... end def redirect_to_widgets_response return [302, { "Content-Type" => "text/html", "Location" => "/widgets" }, "<html><body><a href=\"/widgets\">Redirecting...</a></body></html>"] end # Based on Rails method of the same name but simplified, i.e. no need to check if: # - protection is disabled # - request method is :post # - format is verifiable def verified_request? form_authenticity_token == params['authenticity_token'] end def form_authenticity_token session[:_csrf_token] ||= ActiveSupport::SecureRandom.base64(32) end
More Challenges
There are other challenges you may encounter in writing your Rails Metal app. I have tried rendering a partial by directly using ERB but it’s too ugly to show here. And I’ve also struggled with performance. Not all business logic can simply be translated to a Metal app to be fast. It is recommended for very simple things only, or it may not be worth it. Anyway, I hope this guide clears up a few things. Also, if you have better ways of doing any of the above, feel free to post in the comments. Thanks!