Making a Rails Helper predictable
Rails helpers are pretty messy. Everyone who’s ever tried to simply include, say, the UrlHelper into an object did instantly crash with some NoMethodError either complaining about
- a missing method that should be included in the helper already, but isn’t
- an undefined method for
nil:NilClass
target = Object.new target.extend ActionView::Helpers::UrlHelper target.url_for('/fix/me') #=> url_helper.rb:94:in `url_for': undefined method `escape_once' for #<Object:0xb7496300> (NoMethodError)
That’s due to dependencies between different helpers and the instances they’re mixed into.
Wrapping the UrlHelper
I’m gonna explain how ActiveHelper can help solving this problem by wrapping the UrlHelper step-by-step.
This will make the UrlHelper a predictable, well-behaving class. Helpers should be good kids, nothin’ more.
Here’s a first version for the new helper.
module Rails class UrlHelper < ActiveHelper::Base include ActionView::Helpers::UrlHelper provides :url_for, :link_to, :button_to # add more if you need. end end
We derive the new helper from ActiveHelper::Base, include the original module and define our interface with provides.
A rails scenario setup
In Rails, this is what roughly happens when a view is rendered.
controller = ActionController::Base.new view = ActionView::Base.new([], {}, controller) view.extend ActiveHelper view.use Rails::UrlHelper
Usually the use call would happen in the controller’s action processing, or in #render.
view.url_for('fix/me') # => url_helper.rb:85:in `send': undefined method `url_for' for nil:NilClass (NoMethodError)
Ok, send is accessing nil. Let’s inspect the original helper.
def url_for(options = {}) ... @controller.send(:url_for, options)
The rails helpers blindly accesses an instance var @controller, it simply assumes the instance it is mixed into does provide it.
Even worse, it uses send to invoke a method on the controller. That’s one of rails major drawbacks and must be fixed.
Providing the @controller
Unfortunately, we won’t touch the original UrlHelper and open pandora’s box.
Remember, we got a helper instance which mixed-in the original UrlHelper module. That means we’d just have to provide the @controller instance variable in our helper instance.
module Rails class UrlHelper < ActiveHelper::Base include ActionView::Helpers::UrlHelper provides :url_for, :link_to, :button_to # add more if you need. def initialize(*args) super(*args) @controller = parent.controller end
We setup an ivar @controller by accessing parent, that’s the object which is using us, so it’s the ActionView and that itself does provide a public accessor to its controller.
Fixing another helper dependency
Not nice, but anyway, cleaner than things were before.
view.url_for('fix/me') url_helper.rb:94:in `url_for': undefined method `escape_once' for #<Rails::UrlHelper:0xb747b794> (NoMethodError)
Oh. #escape_once is needed in rails’ original helper, too. I will spare the details about the code now, the method is defined in TagHelper. However it is not mixed in UrlHelper. Shitty helpers.
That’s another broken dependency we’ll have to fix.
We now have three possibilities
- we could wrap
TagHelperin anActiveHelperas well, and deriveUrlHelper - wrapping and
use‘ing it insideUrlHelperwould be the second solution - or simply
includethe original module, hoping all will go well
For now I’ll choose the third, anyway, that’s far from good architecture.
module Rails class UrlHelper < ActiveHelper::Base include ActionView::Helpers::UrlHelper include ActionView::Helpers::TagHelper provides :url_for, :link_to, :button_to # add more if you need. def initialize(*args) super(*args) @controller = parent.controller end
Let’s see if it works.
view.url_for('fix/me') #=> "fix/me" view.button_for('fix/me') #=> url_helper.rb:298:in `button_to': undefined method `protect_against_forgery?' for #<Rails::UrlHelper:0xb74a7eac> (NoMethodError)
Now what’s that again? Looks as if #button_for needs protect_against_forgery?.
Delegating dependency to the ActionView
Let’s express that dependency.
module Rails class UrlHelper < ActiveHelper::Base include ActionView::Helpers::UrlHelper include ActionView::Helpers::TagHelper provides :url_for, :link_to, :button_to # add more if you need. needs :protect_against_forgery? ...
Having that defined, calls to protect_against_forgery? will be delegated back to the view. That should work.
view.button_for("Click me!", 'fix/me') #=> <form method="post" action="fix/me" class="button-to"><div><input type="submit" value="Click me!" /></div></form>
Yeah!
Where to go from here?
Now that you know how easy it is to clean up the Rails helpers (or Merb, or …!!!), go and do it. I’d love to see ActiveHelper being used to replace nasty modules and bringing back the power of OOP to your views.
April 29th, 2010 at 7:34 pm
Interesting approach, thanks for sharing.