Rails generator tutorial part 2: writing a custom manifest action
In part one of this tutorial I wrote a simple rails generator that creates a controller code file in app/controllers. This time I want to finish up my simple “VersionGenerator” example by enabling it to add a line to the routes.rb file. In other words, I want to be able to use a manifest action called “route” like this:
def manifest record do |m| m.template('controller.rb', 'app/controllers/version_controller.rb') m.route :name => 'version', :controller => 'version', :action => 'display_version' end end
The problem for today is how to implement the “route” action, which is not supported by Rails by default. The Rails generator base class code does provide a few other actions you can use in your manifest, like “file” or “directory,” which create a file or directory. Rails even provides a method called “route_resources” to add a “map.resources” line to routes.rb, but not a simple named route line like what we need for this example.
This specific action might be useful for you, but my real goal is to explore how Rails generators work in general and discuss some of the issues you might run into if you need a custom manifest action like this for any purpose. Let’s get started by just adding the new action method we need right inside our generator class…
def route(route_options) puts "This will create a new route!" end
…and see what happens when we run script/generate with manifest method above:
$ ./script/generate version ../build-info/version.txt identical app/controllers/version_controller.rb This will create a new route!
Very promising! All we need to do is add the necessary code here and we’ll be all set. But first let’s take a closer look at what’s happening by adding a call to “caller” inside this new method and see how it’s being called by the Rails generator system:
def route(route_options) puts "This will create a new route!" puts caller end
Here’s what is displayed when we run this (paths shortened for readability):
$ ./script/generate version ../build-info/version.txt identical app/controllers/version_controller.rb This will create a new route! /usr/local/lib/ruby/1.8/delegate.rb:270:in `__send__' /usr/local/lib/ruby/1.8/delegate.rb:270:in `method_missing' /path/to/rails-2.3.2/lib/rails_generator/manifest.rb:47:in `send' /path/to/rails-2.3.2/lib/rails_generator/manifest.rb:47:in `send_actions' /path/to/rails-2.3.2/lib/rails_generator/manifest.rb:46:in `each' /path/to/rails-2.3.2/lib/rails_generator/manifest.rb:46:in `send_actions' /path/to/rails-2.3.2/lib/rails_generator/manifest.rb:31:in `replay' /path/to/rails-2.3.2/lib/rails_generator/commands.rb:42:in `invoke!' /path/to/rails-2.3.2/lib/rails_generator/scripts/../scripts.rb:31:in `run' /path/to/rails-2.3.2/lib/commands/generate.rb:6 /usr/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:31:in `gem_original_require' /usr/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:31:in `require' ./script/generate:3
There’s a lot going on here; in fact, when I started to learn more about Rails generators and how they work I was shocked at how complicated their object model and design are. I won’t try to explain all of it here, but let’s take a closer look at the one line I bolded above: line 31 of lib/rails_generator/scripts.rb
Rails::Generator::Base.instance(options[:generator], args, options) .command(options[:command]).invoke!
This is called when you run script/generate, by the “Rails::Generator::Scripts::Generate” class. Translating this line of Ruby into English, we get: “Create an instance of the specified generator, passing in the arguments and options provided on the command line; then create an instance of the specified command that uses that generator; then invoke the command.”
What this means is that actually Rails generator classes do not do the real work of generating code – Rails generator “commands” do! The lib/rails_generator/commands.rb file defines a series of command objects inside the Rails::Generator::Commands module. The two most important command classes are Create and Destroy. The “Create” command is called when you execute script/generate, like what happened in the stack trace above. This class contains the implementation of the “template” method we used before, plus various other methods like “file” and “directory” that create files and directories, etc.
The “Destroy” command is called when you execute script/destroy from the command line (I removed “puts caller” first before running this):
$ ./script/destroy version ../build-info/version.txt This will create a new route! rm app/controllers/version_controller.rb
This class has all of the same methods that the Create class does, except that they perform the opposite action: instead of creating a file or directory, they delete them. The Destroy class also executes the actions in the opposite order, by looping backwards through the manifest that we recorded in our generator. Here the destroy command has deleted our version_controller.rb file we generated earlier. Note that it still displays “This will create a new route!”
If you’re interesting in diving into the real details about how Rails generators work, then read the code in commands.rb very carefully. In particular, look at how the “self.included” and “self.instance” methods at the top work; these methods are used in the line I showed above to create the specified command, supplying the specified generator as an argument to the command constructor. The “invoke!” method on commands actually plays back the recorded manifest. Also all of the actions that are available to your generator's manifest method are defined in this file. One other interesting detail I don’t have space to explain carefully here is that the command objects contain the corresponding generator class as a delegate object; in other words they contain an instance of the generator as a member variable:
# module Rails::Generator::Commands... class Base < DelegateClass(Rails::Generator::Base)
This explains the top two lines in the stack trace above:
/usr/local/lib/ruby/1.8/delegate.rb:270:in `__send__' /usr/local/lib/ruby/1.8/delegate.rb:270:in `method_missing'
“DelegateClass” is defined by delegate.rb, which is a Ruby library that implements the delegate pattern. This is why we were able to add the new “route” method right in our generator; when Ruby found the “route” method missing in the Create command, it delegated the call to our generator object.
Obviously we have a problem here: our new “route” method needs to be able to remove a route from routes.rb if the Destroy command is called, as well as create a new one for the Create command. One way to implement our new “route” method would be check the “options[:command]” value that we saw above on line 31 of scripts.rb, like this:
def route(route_options) if options[:command] == :create puts "This will add a new route to routes.rb." elsif options[:command] == :destroy puts "This will remove the new route from routes.rb." end end
Now if we run script/generate again, our new method will create a route:
$ ./script/generate version ../build-info/version.txt create app/controllers/version_controller.rb This will add a new route to routes.rb.
And if we run the destroy command, we’ll remove the route:
$ ./script/destroy version ../build-info/version.txt This will remove the new route from routes.rb. rm app/controllers/version_controller.rb
It turns out a cleaner way to implement the “route” method is to directly add the method to both the Create and Destroy command classes. This allows me to call a utility method called “gsub_file” in the Rails::Generator::Commands::Base class which I wouldn’t have direct access to from my generator class. It also avoids the somewhat ugly if statement on the options[:command] value, and finally it might make it easier for me someday to refactor the new route methods into a separate module that I could use with various different generators that might need to add and remove routes.
Anyway, here’s the finished code for the entire generator:
class VersionGenerator < Rails::Generator::NamedBase attr_reader :version_path def initialize(runtime_args, runtime_options = {}) super @version_path = File.join(RAILS_ROOT, name) end def manifest record do |m| m.template('controller.rb', 'app/controllers/version_controller.rb') m.route :name => 'version', :controller => 'version', :action => 'display_version' end end end module Rails module Generator module Commands class Base def route_code(route_options) "map.#{route_options[:name]} '#{route_options[:name]}', :controller => '#{route_options[:controller]}', :action => '#{route_options[:action]}'" end end # Here's a readable version of the long string used above in route_code; # but it should be kept on one line to avoid inserting extra whitespace # into routes.rb when the generator is run: # "map.#{route_options[:name]} '#{route_options[:name]}', # :controller => '#{route_options[:controller]}', # :action => '#{route_options[:action]}'" class Create def route(route_options) sentinel = 'ActionController::Routing::Routes.draw do |map|' logger.route route_code(route_options) gsub_file 'config/routes.rb', /(#{Regexp.escape(sentinel)})/mi do |m| "#{m}\n #{route_code(route_options)}\n" end end end class Destroy def route(route_options) logger.remove_route route_code(route_options) to_remove = "\n #{route_code(route_options)}" gsub_file 'config/routes.rb', /(#{to_remove})/mi, '' end end end end end
More or less copied from the existing route_resources action found in commands.rb, this works as follows: If we call script/generate then the route action method in the Create command class is called. This uses gsub_file to look for the “sentinel” or target area of the file and replaces it with the sentinel + the new route code. I also use the “logger” method to display log messages using the Rails::Generator::SimpleLogger class. Here’s what it looks like on the command line:
$ ./script/generate version ../build-info/version.txt create app/controllers/version_controller.rb route map.version 'version', :controller => 'version', :action => 'display_version'
If script/destroy is called, then the second route implementation in the Destroy class is called. This uses gsub_file to remove the route code. Finally, the “route_code” method returns the route code that we want to generate or remove from routes.rb. This time on the command line we get:
$ ./script/destroy version ../build-info/version.txt remove_route map.version 'version', :controller => 'version', :action => 'display_version' rm app/controllers/version_controller.rb