Creating Resourceful Plugins

It's been some time since we originally proposed the idea of resourceful plugins—a solution to the persistent problem of integrating distinct Rails applications. Over the past year, we've worked on refining the approach (in the context of our two main resourceful projects, Sandstone and Bloget), and in the course of preparing for the tutorial I gave at Railsconf Europe, we've been able to formalize (somewhat) the process of creating resourceful plugins.

Extraction

Like most good, reusable code, the best resourceful plugins are extracted from an existing app, not created ex nihilo. I generally recommend creating a new plugin only after you've built the same functionality into an application at least twice—that way, you've worked through the same problems at least two times, and should be able to create a much better solution than you'd have been able to at the start.

Once you've built the functionality into an application, the extraction process isn't that complicated. You start with the Rails plugin generator (in the examples that follow, I'll use the Bagpipes plugin):

 script/generate plugin --with-generator Bagpipes 

This command creates the scaffolding for a plugin, including the basics for a new Rails generator (the --with-generator option). It does create some files and folders you may not need, however, so for this example you can delete the following:

 /init.rb /install.rb /Rakefile /uninstall.rb /lib/bagpipes.rb /test/* 

Moving Code

Once you've got the framework in place, you can start to fill it in by moving code from the application to the plugin. Start by creating controllers/ and models/ folders in lib/bagpipes, and within those, create blank files for each controller and model you'll be extracting. For Bagpipes, that would result in the following:

 /lib/bagpipes/controllers/members_controller.rb /lib/bagpipes/controllers/messages_controller.rb /lib/bagpipes/controllers/topics_controller.rb /lib/bagpipes/models/member.rb /lib/bagpipes/models/message.rb /lib/bagpipes/models/topic.rb 

Each of these files will contain the code you wrote for the application, but namespaced into a module. The Member module, for instance, ends up looking like this:

 module Bagpipes module Models module Member def self.included(base) base.class_eval do validates_presence_of :name validates_presence_of :user has_many :messages, :dependent => :destroy belongs_to :user, :polymorphic => true end end end end end 

The self.included(base) method allows you to call the various ActiveRecord validation and association macros when you include this module into a model; it also appears in the controller modules:

 module Bagpipes module Controllers module MembersController def self.included(base) base.class_eval do before_filter :require_member_admin end end def index @members = Member.all end def new @member = Member.new end def create @member = Member.new(params[:member]) if @member.save flash[:notice] = "The member #{@member.name} has been created" redirect_to members_path else flash[:form_error] = @member render :action => 'new' end end private def require_member_admin unless logged_in? && current_user.member && current_user.member.administrator? flash[:error] = "You must be a forum administrator to do that" redirect_to topics_path end end end end end 

In addition to the self.included(base) method, you can also add instance methods (for controllers, actions) to these modules.

One of the benefits of the extraction method is that it makes this easy to test; since you're pulling code out of an application into a plugin for that same application, all you have to do is this in the original controller:

 class MembersController < ApplicationController include Bagpipes::Controllers::MembersController end 

If you rerun your tests, then, you should get the same results you did before you moved the code.

Templates

Once you've moved all your existing code into your new plugin, you'll need to set up the templates. At a minimum, you'll want to create folders in generators/bagpipes for your controllers, migrations, models, tests, and views (you might also need folders for helpers and other files, but we'll ignore those for this example). Once those are in place, just copy the files from your application into the new folders—the only change required is that you consolidate multiple migrations into a single file, and remove the timestamp identifier on it:

 class ConsolidatedBagpipesMigration < ActiveRecord::Migration def self.up create_table :members do |t| t.string :name t.boolean :administrator, :default => false t.datetime :created_at t.datetime :updated_at t.integer :user_id, :null => false t.string :user_type, :null => false end create_table :messages do |t| t.integer :topic_id t.integer :parent_id t.string :title t.text :content t.datetime :created_at t.datetime :updated_at t.integer :member_id end add_index :messages, [:topic_id], :name => :index_messages_on_topic_id add_index :messages, [:parent_id], :name => :index_messages_on_parent_id create_table :topics do |t| t.string :title t.text :description t.datetime :created_at t.datetime :updated_at end end def self.down drop_table :messages drop_table :members drop_table :topics end end 

Infrastructure

The remaining tasks are simple. First, you need to create the generator file itself:

 class BagpipesGenerator < Rails::Generator::Base def manifest template_dir = File.join(File.dirname(__FILE__), 'templates') record do |m| m.class_collisions "Message", "Member", "Topic" m.class_collisions "TopicsController", "MembersController", "MessagesController" Dir.chdir(template_dir) do # handle models, controllers, and views %w(models controllers views).each do |area| m.directory(File.join('app', area)) Dir.glob(File.join(area, '**', '*')).each do |file| m.directory(File.join('app', file)) if File.directory?(file) m.file(file, File.join('app', file)) if File.file?(file) end end # handle tests m.directory('test') Dir.glob(File.join('test', '**', '*')).each do |file| m.directory(file) if File.directory?(file) m.file(file, file) if File.file?(file) end end # insert routes sentinel = 'ActionController::Routing::Routes.draw do |map|' gsub_file 'config/routes.rb', /(#{Regexp.escape(sentinel)})/mi do |match| "#{match}\n map.resources :members\n map.resources :topics, :has_many => [:messages]\n map.new_reply 'topics/:topic_id/parent/:parent_id', :controller => 'messages', :action => 'new'\n" end # insert member association print 'What is the name of your user class? [User] ' user_model = STDIN.gets.chomp user_model = 'User' if user_model == '' sentinel = "class #{user_model} < ActiveRecord::Base" gsub_file "app/models/#{user_model.downcase}.rb", /(#{Regexp.escape(sentinel)})/mi do |match| "#{match}\n has_one :member, :dependent => :destroy, :as => :user\n" end # Handle migrations Dir.glob(File.join(template_dir, 'migrate', '*')).each do |file| m.migration_template( File.join('migrate', File.basename(file)), 'db/migrate', :migration_file_name => File.basename(file, '.rb') ) end m.readme '../../../README' end end def gsub_file(relative_destination, regexp, *args, &block) path = destination_path(relative_destination) content = File.read(path).gsub(regexp, *args, &block) File.open(path, 'wb') { |file| file.write(content) } end end 

This code copies the templates into your application and inserts resourceful routes. It then prompts for the integration point (for Bagpipes, the user class), so that it can insert the appropriate association declaration. Finally, it adds the resource migrations and displays the README.

The final step is the addition of a rake task for the plugin:

 namespace :bagpipes do task :load_rails do unless Kernel.const_defined?('RAILS_ROOT') Kernel.const_set('RAILS_ROOT', File.join(File.dirname(__FILE__), '..', '..', '..')) end if (File.exists?(RAILS_ROOT) && File.exists?(File.join(RAILS_ROOT, 'app'))) require "#{RAILS_ROOT}/config/boot" require "#{RAILS_ROOT}/config/environment" require 'rails_generator' require 'rails_generator/scripts/generate' end end desc "Installs Bagpipes" task :install => :load_rails do Rails::Generator::Scripts::Generate.new.run(['bagpipes']) end end 

With this in place, users can integrate the plugin by adding it and running rake bagpipes:install, making for a friendlier installation.

Summary

That's it—certainly, there's more you can do (helpers, CSS), but all such tweaks remain within this framework. At the very least, you should fill in the README, USAGE, and other such files. With the work done here, however, you're ready to add this functionality to any application that might need it.

Ben Scofield

,
Posted in Article Category: #Code
on