Understanding how Rails handles requests from routes.rb to calling the action method on a controller makes it possible to build Rails plugins ranging from Hanami-like action classes to component-driven Rails development.
Have you ever opened a Rails controller that looks like this and wondered how it knows to render the view ./app/views/show.html.erb
?
class PostsController
before_action :load_post
# How does Rails know to render ./app/views/show.html.erb
# when I don't write the `#show` method in my controller?
private
def load_post
@post = Post.find(params[:id])
end
end
Rails has a method called method_for_action
that Rails calls to perform this magic. At first glance it doesn’t seem like much, but it turns out we can do some pretty nifty things with this method, like build a Rails application entirely from components using nothing but Phlex classes.
Replace def show
with class Show
Paste the code snippet in the controller concerns directory at ./app/controller/concerns/phlexable.rb
and you’ll be able embed Phlex views in your application controllers with the name Show
, Edit
, etc. to handle requests from show
, edit
, etc. respectively.
Let’s have a look at the entirety of the code.
module Phlexable
extend ActiveSupport::Concern
class_methods do
# Finds a class on the controller with the same name as the action. For example,
# `def index` would find the `Index` constant on the controller class to render
# for the action `index`.
def phlex_action_class(action:)
action_class = action.to_s.camelcase
const_get action_class if const_defined? action_class
end
end
protected
# Assigns the instance variables that are set in the controller to setter method
# on Phlex. For example, if a controller defines @users and a Phlex class has
# `attr_writer :users`, `attr_accessor :user`, or `def users=`, it will be automatically
# set by this method.
def assign_phlex_accessors(phlex_view)
phlex_view.tap do |view|
view_assigns.each do |variable, value|
attr_writer_name = "#{variable}="
view.send attr_writer_name, value if view.respond_to? attr_writer_name
end
end
end
# Initializers a Phlex view based on the action name, then assigns `view_assigns`
# to the view.
def phlex_action(action)
assign_phlex_accessors self.class.phlex_action_class(action: action).new
end
# Phlex action for the current action.
def phlex
phlex_action(action_name)
end
# Try rendering with the regular Rails rendering methods; if those don't work
# then try finding the Phlex class that corresponds with the action_name. If that's
# found then tell Rails to call `default_phlex_render`.
def method_for_action(action_name)
super || if self.class.phlex_action_class action: action_name
"default_phlex_render"
end
end
# Renders a Phlex view for the given action, if it's present.
def default_phlex_render
render phlex
end
end
The more interesting bit in this concern is method_for_action
, which is what drives Rails “implicit rendering”.
Let’s break it down.
ActionController::ImplicitRender#method_for_action
This is a method in Rails that Rails asks, “what method should I call to render the action given to me by the router?”. At first you might be thinking, “that’s easy! If I route to posts#index
just call index
! Yes, but have you ever wondered how Rails finds ./app/views/posts/index.erb
if you don’t define an index method? It does so through method_for_action
.
For the Phlex component renderer, I call super
first, which is Rails default rendering stack. If super
returns nil, meaning no methods or view templates were found to render the requested action, I check if the Phlex class exists in my controller. If the class does exist, I return the string "default_phlex_render"
, which Rails then calls to render the action.
# Try rendering with the regular Rails rendering methods; if those don't work
# then try finding the Phlex class that corresponds with the action_name. If that's
# found then tell Rails to call `default_phlex_render`.
def method_for_action(action_name)
super || if self.class.phlex_action_class action: action_name
"default_phlex_render"
end
end
default_phlex_render
method_for_action
returned the "default_phlex_render"
string, which Rails then calls via self.send("default_phlex_render")
to create an instance of the Phlex view and pass it to Rails built-in render
method.
# Renders a Phlex view for the given action, if it's present.
def default_phlex_render
render phlex
end
The phlex
method returns an instance of a Phlex class for the requested action.
# Initializers a Phlex view based on the action name, then assigns `view_assigns`
# to the view.
def phlex_action(action)
assign_phlex_accessors self.class.phlex_action_class(action: action).new
end
# Phlex action for the current action.
def phlex
phlex_action(action_name)
end
action_name
is an Action Controller method that returns the action as resolved by the router. For the sake of example, let’s say we’re requesting /blog/1/posts
—the Rails router might resolve that to the PostsController
controller and the index
action name. The "index"
string gets passed into phlox_action
method, which it turns into a class name Index
to check if it exists at PostsController::Index
via the const_get
method.
def phlex_action_class(action:)
action_class = action.to_s.camelcase
const_get action_class if const_defined? action_class
end
If the class does exist on the controller, an instance of it is created and then we assign the instance variables from the controller into the Phlex view class only if it has a setter defined.
This method could be modified to also look for views in other class hierarchies, like View::Posts::Index
, for example.
def assign_phlex_accessors(phlex_view)
phlex_view.tap do |view|
view_assigns.each do |variable, value|
attr_writer_name = "#{variable}="
view.send attr_writer_name, value if view.respond_to? attr_writer_name
end
end
end
Finally we copy the instance variables that were set in the controller into the class, but only if the Phlex view component has a setter method that matches the instance variable name. For example, @posts
in the controller would set Index#posts=
. Why not simply copy the instance variables from the controller into the Phlex view? Because it make the component leaky and harder to test since it breaks encapsulation. Think about it, would you rather setup a view component test like this?
# Eww, nobody wants to initialize a view this way
index = Index.new
index.instance_variable_set("@blogs", Blog.all)
Or like this?
# That's a little better
index = Index.new
index.blogs = Blog.all
The latter is easier to test and is preferred, but if for whatever reason you wanted to copy over all the instance variables directly into the class from the controller, now you know how to do it.
Taking it further
In this example we used Phlex classes to render Rails views, but now that you know how to dispatch a request to a class embedded in a Rails controller, there’s lots that could be done including:
ViewComponent integration
If the ViewComponent library is your cup of tea, the same technique used to render Phlex views could be applied to the ViewComponent gem.
Action classes
If you have code that’s heavy on before_action
and after_action
calls and inheriting ActionControllers doesn’t make sense, it’s possible to build Hanami-style action classes that can be nested in an Action Controller. It’s not 100% the same thing, but maybe its an abstraction you need for your application.
More sophisticated Phlex view class lookups
If you decide to build a Rails app entirely from Phlex components, you could look up constants in different name spaces like Views::#{controller.name}::#{controller.action_name}
, or break it out into formats like Views::#{controller.name}::#{request.format}::#{controller.action_name}
.
Format classes
If you’re constantly implementing format.html
, format.json
blocks in your application, you could replace them with a responder class.
class ApplicationResponder
attr_accessor :model, :controller
delegate :action_name, :render, to: :controller
def html
render action_name
end
def json
@model.as_json
end
end
Batch resource manipulation
Handling batches of resources in Rails in a secure manner can get awkward because Rails wants each action to have its own URL, but rendering an HTML form with a list of check boxes next to each item in a form results in a request payload that looks like this:
{
batch: {
selected: [1,2,3,4,5,6,7],
action: "delete
}
}
method_for_action
can match params[:batch][:action]
to the correct action in our batch controllers and make sure the endpoints are publicly accessible routes with this little snippet of code:
module Batchable
extend ActiveSupport::Concern
protected
def method_for_action(action_name)
routable_batch_action? ? batch_action : super
end
def batch_action
params.dig("batch", "action")
end
def routable_batch_action?
self.class.action_methods.include? batch_action
end
end
Which we connect to our routes via this nifty little helper.
Rails.application.routes.draw do
resources :blogs do
nest :posts do
batch :delete, :publish
end
end
end
There’s a more that goes into batch, which I’ll cover in a future post.