We’ve started extracting simple behavior into Rails Engines lately. An example of this is our Invitable engine. As you may have guessed, it adds invitation request support to an existing app. It’s about 50% complete right now but for the purposes of this post it will act as the example.
As an engine it has a very slim Invitation
model that only
expects an email
attribute. A client app we’re currently
building requires two additional attributes to be gathered: name
and zipcode
.
There is no need to overwrite the model, I just want to extend it. The cleanest thing to do is just monkey patch it.
Let’s start with writing the spec of where I want the model to be (I am using ValidAttribute if the specs don’t look familiar, I suggest you try it test spec your validations)
require 'spec_helper'
describe Invitable::Invitation do
it { should have_valid(:name).when('Henry Ford') }
it { should_not have_valid(:name).when(nil, '') }
it { should have_valid(:zipcode).when('02115') }
it { should_not have_valid(:zipcode).when(nil, '', 'hello', '0211', '021156') }
end
To make this spec green there are two things that I have to do
- Add the
name
andzipcode
columsn to the correct table - Open up the class and add the proper validations on those attributes
The first is simple. I just create a new migration and add the columns
to invitable_invitations
.
The second is not so straight forward. If I open up the class in the client app and attempt to add the validations like so:
module Invitable
class Invitation
validates :name, :zipcode, :presence => true
validates :zipcode, :format => /^\d{5}$|^\d{5}-\d{4}$/
end
end
The app will raise a NoMethodError
exception complaining that
validates
is undefined. In the load path there are two
app/models/invitable/invitation.rb
files and the one in the app takes precendence
over the one in the engine. This is fine because you might want to
overwrite the model entirely, but in this case I want to extend it. So
you must explicitly require the engine’s model at the top of the app’s model.
Thankfully the engine itself has a nice helper called_from
that tracks its full path
on the file system. In this example we access it with
Invitable::Engine.called_from
. This will point to the lib/invitable
directory
in the gem itself. Here is what I ended up with in the model:
require File.expand_path('../../app/models/invitable/invitation', Invitable::Engine.called_from)
module Invitable
class Invitation
validates :name, :zipcode, :presence => true
validates :zipcode, :format => /^\d{5}$|^\d{5}-\d{4}$/
end
end
It’s verbose and this could be better so let’s clean that up.
In my engine I’ve added a spec to spec/lib/invitable/engine_spec.rb
with the following (I’m using Mocha for the stubbing)
require 'spec_helper'
describe Invitable::Engine do
before { Invitable::Engine.stubs(:called_from).returns('/lib/invitable') }
describe '.app_path' do
it 'returns the path to the engine app directory' do
Invitable::Engine.app_path.should eq '/app'
end
end
describe 'controller_path' do
it 'returns the path to the named engine controller' do
Invitable::Engine.controller_path(:test_controller).should eq '/app/controllers/invitable/test_controller.rb'
end
end
describe 'helper_path' do
it 'returns the path to the named engine helper' do
Invitable::Engine.helper_path(:test_helper).should eq '/app/helpers/invitable/test_helper.rb'
end
end
describe 'mailer_path' do
it 'returns the path to the named engine mailer' do
Invitable::Engine.mailer_path(:test_mailer).should eq '/app/mailers/invitable/test_mailer.rb'
end
end
describe 'model_path' do
it 'returns the path to the named engine model' do
Invitable::Engine.model_path(:test_model).should eq '/app/models/invitable/test_model.rb'
end
end
end
This looks good enough to me. Now to make it green I added the following
to lib/invitable/engine.rb
def self.app_path
File.expand_path('../../app', called_from)
end
%w{controller helper mailer model}.each do |resource|
class_eval <<-RUBY
def self.#{resource}_path(name)
File.expand_path("#{resource.pluralize}/invitable/\#{name}.rb", app_path)
end
RUBY
end
And now in the app model I can do the following
require Inivitable::Engine.model_path :invitation
module Invitable
class Invitation
validates :name, :zipcode, :presence => true
validates :zipcode, :format => /^\d{5}$|^\d{5}-\d{4}$/
end
end
Nice and clean!
This simple pattern can be applied to the controllers, mailers, etc… any class you want to actually extend from the engine instead of overwrite entirely.
Finally, I’d like the address a question I’m sure some of you have. Why
not subclass? For this engine the Invitable::InvitationsController
is
expecting a class of Invitation
within the context of the Invitable
module. So if I were to subclass
class Inivtation < Inivitable::Invitation
You would then have to subclass the controller
class InvitationsController < Invitable::InvitationsController
And because the InvitationsController
is referencing
InvitationMailer
within the context of the Invitable
module you
would have to subclass the mailer
class InvitationMailer < Invitable::InvitationMailer
Finally, because you’ve subclassed the controller the mount in
routes.rb
becomes meaningless. If you head down the subclass path you
defeat the purpose of using the engine in the first place.