Close and Go BackBack to Viget

Creating Resourceful Plugins

Ben Scofield
Ben Scofield, Development Director, September 30, 2008 6

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.

Pedro Pimentel said on 09/30 at 12:13 PM

I was in your tutorial session, it was great and this post just came to state it.

Ben Scofield said on 09/30 at 01:17 PM

Thanks, Pedro! Glad you enjoyed it :)

donny said on 10/14 at 11:27 AM

I’m new to PHP and recently setup my local machine with PHP and MySQL for doing development.  I was sort of stuck when I needed to post my work for the user to test and review.  After looking around a bit I found a site that hosts PHP and MySQL apps.  I was surprised that it was free - it seems they’re offering the service at no cost until 2012.  At that point they’ll change over to a fee-based service.  However, in the meantime, it’s a great place to do anything from demo and sandbox right up to posting sites for real.

Their pitch is as follows:

“This is absolutely free, there is no catch. You get 350 MB of disk space and 100 GB bandwidth. They also have cPanel control panel which is amazing and easy to use website builder. Moreover, there is no any kind of advertising on your pages.”

Check it out using this link:

http://www.000webhost.com/83188.html

Important:  There’s one catch in that you must make sure you visit the account every 14 days - otherwise the account is marked ‘Inactive’ and the files are deleted!!!

Thanks and good luck!

Parduotuviu kurimas said on 10/20 at 02:02 PM

However, in the meantime, it’s a great place to do anything from demo and sandbox right up to posting sites for real.

Anonymous said on 10/21 at 07:59 AM

But why would anyone want to do this?

Jon Williams said on 11/28 at 11:02 AM

Nice article. I love using plugins.

Commenting is not available in this weblog entry.

We're the Developers

at Viget Labs. We write about web development trends, tips, best practices, industry events, and our projects — all with an emphasis on Ruby on Rails.

Recent Comments

Tony,

I understand and agree that the back-end shouldn’t output code (html code), and only content. The templates (aka views) should do the trick, but instead of having lot’s of if/else conditionals inside the view, you may just output the following content.

No information available

The template would loop in an array and put all the <li>’s inside the <ul>.
I don’t see anything wrong, nor...