It's hard to avoid using Devise in a Rails project. That's mostly because the ease of using it is hard to beat.
Devise is incredibly configurable and that often means having code that can be hard to follow. Providing places in a library for configuration options and hooks for adding features is a difficult job.
But metaprogramming in Ruby can be a flexible way to make a library easy to configure.
Knowing metaprogramming helps you understand tools better, and if you don't know metaprogramming, you just might feel lost. Iterating over arrays and building up strings with define_method
and other things going on requires a lot of mental effort to follow.
The good news is that it's not as difficult to understand as you might expect when first reading it.
So let's dip our toes into the water to get a better understanding of metaprogramming and Devise and start with one of the first things you'll add to your rails project: devise_for :users
. We'll see how it builds the helpers that become available to your controllers and views.
We're going to walk through the file in lib/devise/controllers/url_helpers.rb as of commit 9aa17eec07719a97385dd40fa05c4029983a1cd5 but later commits are probably similar.
When you add that line to your routes.rb file to generate the routes for your :users
, Devise begins generating the code for your controllers.
When I want to understand how something works in a library I'll start searching for the definition of a particular method. In this case I searched the source code for "def devise_for"
which brought me to
lib/devise/rails/routes.rb
.
I find the method I want in there and start reading through for anything that refers to "helpers". Unfortunately nothing stands out. But I do see several references to "mapping" and it seems important to this method so maybe I'll find what I need if I follow that.
The first reference to mapping
looks like this:
resources.each do |resource|
mapping = Devise.add_mapping(resource, options)
So we should probably find out what add_mapping
does.
My first search fo "def add_mapping"
returned nothing. So I looked for just "add_mapping"
instead and saw a result listing def self.add_mapping
.
Of course, a class method. That's one of the challenges with searching through a Ruby code base. That same method could have been defined as:
class << self
def add_mapping
Or even as
def Devise.add_mapping
And other possibilities as well. But we found it and the "add_mapping"
search would have been good enough to find those other options too.
def self.add_mapping(resource, options)
mapping = Devise::Mapping.new(resource, options)
@@mappings[mapping.name] = mapping
@@default_scope ||= mapping.name
@@helpers.each { |h| h.define_helpers(mapping) }
mapping
end
There's a Mapping
constant referenced in there which looks interesting, but this line is what I'm after:
@@helpers.each { |h| h.define_helpers(mapping) }
At this point, I'm going to assume that Mapping has some details on it for when we setup routes for :users
or whatever else we choose. That's good enough for now and I can search for more detail later if I need it.
So what is @@helpers
?
It's defined earlier in the file like this:
mattr_reader :helpers
@@helpers = Set.new
@@helpers << Devise::Controllers::Helpers
That means that h.define_helpers(mapping)
is calling define_helpers
on Devise::Controllers::Helpers
. So let's go look at that method...
def self.define_helpers(mapping) #:nodoc:
mapping = mapping.name
class_eval <<-METHODS, __FILE__, __LINE__ + 1
def authenticate_#\{mapping\}!(opts=\{\})
opts[:scope] = :#\{mapping\}
warden.authenticate!(opts) if !devise_controller? || opts.delete(:force)
end
def #\{mapping\}_signed_in?
!!current_\{mapping\}#
end
def current_\{mapping\}#
@current_\{mapping\}# ||= warden.authenticate(scope: :#\{mapping\})
end
def #_session
current_#\{mapping\} && warden.session(:#\{mapping\})
end
METHODS
ActiveSupport.on_load(:action_controller) do
if respond_to?(:helper_method)
helper_method "current_#\{mapping\}", "#\{mapping\}_signed_in?", "#\{mapping\}_session"
end
end
end
The first part of this is simple. define_helpers
receives a mapping
object and the local variable mapping
is assigned to the mapping.name
. A bit confusing, but simple: mapping = mapping.name
Then it gets into the metaprogramming.
If you're new to metaprogramming, the next line looks weird:
class_eval <<-METHODS, __FILE__, __LINE__ + 1
All of that can be easily explained but when I am trying to figure out what's going on, I look for things I recognize and go from that. I prefer to skip over what I don't understand in favor of starting with something more comfortable.
The bits I recognize are the lines defining methods:
def authenticate_#\{mapping\}!(opts=\{\})
def #\{mapping\}_signed_in?
def current_#\{mapping\}
def #\{mapping\}_session
I'm familiar with string interpolation and this looks exactly like it. Every instance of #\{mapping\}
will be replaced with the string representation of :users
which we specified in our routes_for
.
When the rails app boots up, the methods I need will be created for me. I put routes_for :users
in my routes.rb file and I'll get:
def authenticate_user!(opts={})
def user_signed_in?
def current_user
def user_session
Stepping back up we'll see the class_eval
call which evaluates a block or string in the context of the current class.
The content between <<-METHODS
and METHODS
is seen as as a string by Ruby. We call this a "heredoc". So the heredoc builds up what the methods should look like if you typed them out yourself, and then is given to class_eval
which will evaluate the string and turn it in to real live Ruby methods.
A heredoc marks the start and end of a string. But as you see in this example, the code immediately after the start of the heredoc isn't really a part of it. class_eval
receives the heredoc start, and then a reference to the file being evaluated, and the line number where the evaluation starts.
So __FILE__
provides the file name, and __LINE__
provides the current line. So what's with that +1
in there? Well if there's ever an error in your code this helps it report the correct line.
If, for example, an error is raised it the warden.authenticate!(opts)
code within the def authenticate_#\{mapping\})
definition, you'll want to see the correct line.
That code (as of the commit referenced above) is on line 118. The class_eval
line is on line 115 but the code in the heredoc doesn't really begin until the next line, line 116.
So __LINE__
will return 115 and the +1
just bumps it to 116. An error in the warden.authenticate!
line will properly report line 118. If we didn't add 1 to the starting line given to class_eval
, we'd get an error reporting on line 117. That would be confusing.
To clarify this, try playing with it yourself. Create a file called class_eval_error.rb
and paste this into it:
class Tester
class_eval <<-METHODS, __FILE__, __LINE__+1
def problem
raise "oopsie!"
end
METHODS
end
Tester.new.problem
Then run ruby class_eval_error.rb
in your terminal from the directory where you created the file. If your like me you'll see something like:
class_eval_error.rb:4:in `problem': oopsie! (RuntimeError)`
If you remove that +1
you'll see the error reported on line 3 instead of 4.
Let's jump back to Devise.
After these methods are creating using class_eval
there's a block of code that tells your rails application controller to make some of the generated methods available to your views:
ActiveSupport.on_load(:action_controller) do
if respond_to?(:helper_method)
helper_method "current_#\{mapping\}", "#\{mapping\}_signed_in?", "#\{mapping\}_session"
end
end
The helper_method
method will receive (after string interpolation) three arguments of "current_user"
,"user_signed_in?"
, and "user_session"
.
So that's how the methods are created for your controllers and become available in your views.
If you've read the Ruby DSL Handbook you may be familiar with some of this. But I'm working on building out a deep dive with the Ruby Metaprogramming MasterClass at master-class.saturnflyer.com.
To get more Ruby tips and find out when the MasterClass will be complete, hop on my mailing list at www.saturnflyer.com/subscribe
Stay tuned for more on that in the future. Until then, don't be afraid to dive into the source code to get familiar with how your dependencies work.