Demystifying Rails Plugin Development

Nick Plante ::
Voices That Matter Professional Ruby Conference
November 18th, 2008

Obligatory Introduction
 Plugins

are generalized, reusable code libraries  Extend or override core functionality of Rails  Can save you a lot of time
 Provide
 Rails

standard hooks, helpers…

scripts, hooks  Generators, etc

Plugin Examples

User Authentication

Restful Authentication, Closure

Pagination

Will Paginate, Paginating Find

Asynchronous Processing

Workling, Background Job, etc

View Helpers

Lightbox helper, Flash media player helper, etc

Why Develop Plugins?
 Internal

re-use, productivity booster  Opportunity to refactor, clean up project code
 is_rateable

vs. gobs of in-line ratings code

 Contribute

to the Ruby OSS

ecosystem
 Get

feedback, contributions, inspire others  Profit^h^h^h^h^h^h“Marketing”

Plugin Genesis: Extraction
 Most

plugins don’t start out as plugins extracted & generalized from a useful feature created in a larger project interesting problems have you solved lately?

 Usually

 What

Validating ISBNs
How would we implement this in a Book model for a plain old Rails project?
ISBN-13: 978-1-59059-993-8 ISBN-10: 1-59059-993-4

class Book < ActiveRecord::Base validates_presence_of :title, :author, :isbn def validate unless self.isbn_valid? errors.add(:isbn, "is not a valid ISBN code") end end ISBN10_REGEX = /^(?:\d[\ |-]?){9}[\d|X]$/ ISBN13_REGEX = /^(?:\d[\ |-]?){13}$/ def isbn_valid? !self.isbn.nil? && (self.isbn10_valid? || self.isbn13_valid?) end # ... end

Wait! There’s More!

# more code in your model… def isbn10_valid? if (self || '').isbn.match(ISBN10_REGEX) isbn_values = self.isbn.upcase.gsub(/\ |-/, '').split('') check_digit = isbn_values.pop # last digit is check check_digit = (check_digit == 'X') ? 10 : check_digit.to_i sum = 0 isbn_values.each_with_index do |value, index| sum += (index + 1) * value.to_i end (sum % 11) == check_digit else false end end

Getting Kind of Messy…

# and yet more code in your model… def isbn13_valid? if (self || '').isbn.match(ISBN13_REGEX) isbn_values = self.isbn.upcase.gsub(/\ |-/, '').split('') check_digit = isbn_values.pop.to_i # last digit is check sum = 0 isbn_values.each_with_index do |value, index| multiplier = (index % 2 == 0) ? 1 : 3 sum += multiplier * value.to_i end (10 - (sum % 10)) == check_digit else false end end

Your Model Code

Extract!
 Let’s

clean up that messy model

 Encapsulation

& Information Hiding  Makes our model easier to read
 We

can move this code to a module in lib
 Or

yank it all out into a plugin!

 Either

way, we need to build a validation module, right?

Encapsulation

Designing the Interface
 We
  

need a clean interface

DON’T judge a book by its cover But DO judge a plugin by its interface KISS -- there is beauty in simplicity / minimalism

 Goal
 

is often to extend the Rails DSL in a natural way
We have pre-existing examples to guide our hand ActiveRecord::Validations (see api.rubyonrails.org)

ActiveRecord Examples
acts_as_list :scope => :todo_list validates_http_url :link is_indexed :fields => ['created_at', 'title’] has_attached_file :cover_image, :styles => { :medium => "300x300>", :thumb => "100x100>” }

ActionController Examples
class BooksController < ApplicationController sidebar :login, :unless => :logged_in? permit "rubyists and wanna_be_rubyists" include SomePluginModule def index @books = Book.paginate :page => params[:page], :per_page => 20 end end

ActionView Examples (View Helpers)
<%= lightbox_link_to “My Link”, “image.png” %>

A Little Cleaner, Right? Yeah.
class Book < ActiveRecord::Base validates_isbn :isbn, :with => :isbn13, :unless => :skip_validation end

validates_isbn
 We

need to create a validates_isbn class method on ActiveRecord::Base
 Generalize
 No

our code a bit

longer married to a particular model attribute

 Hide

it behind a Rails-ish DSL AR::Base with our own module

 Extend

module IsbnValidation # may want to namespace this def validates_isbn(*attr_names) config = { :message => "is not a valid ISBN code" } config.update(attr_names.extract_options!) validates_each(attr_names, config) do |record,attr_name,value| valid = case config[:with] when :isbn10; validate_with_isbn10(value) when :isbn13; validate_with_isbn13(value) else validate_with_isbn10(value) || validate_with_isbn13(value) end record.errors.add(attr_name, config[:message]) unless valid end end # other methods, constants go here too end

Making of the Module
 Why
 

is it a module, and not a class?

A module is like a degenerate abstract class You can mix a module into a class
Include with a module to add instance methods  Extend with a module to add class methods

Also use modules for organization

Group similar things together, namespacing

Mixing It Up with Modules
 Don’t

have to stash this module in a plugin

Can use it directly from lib, too…

require ‘isbn_validation’ class Book < ActiveRecord::Base extend IsbnValidation validates_isbn :isbn end

Pluginizing
 But  So

why not go the extra step?

you can easily reuse it across projects  And share with others

Generate a Plugin Skeleton
 Use

the supplied plugin generator
less we have to do, the better!

 The

$ ruby script/generate plugin isbn_validation
in vendor/plugins/isbn_validation: - lib/ - isbn_validation.rb - tasks/ - isbn_validation_tasks.rake - test/ - isbn_validation_test.rb - README - MIT-LICENSE - Rakefile - init.rb - install.rb - uninstall.rb

Plugin Hooks: Install.rb
 Auto-run
 via

when plugin is installed

script/plugin install

 Potential
 Display

Uses

README  Copy needed images, styles, scripts
 Remove

them with uninstall.rb

Plugin Hooks: Init.rb
 Runs

whenever your application is started  Use it to inject plugin code into the framework

Add class methods in IsbnValidation module to AR::Base

ActiveRecord::Base.class_eval do extend IsbnValidation end

Adding Instance Methods?
 Use

include instead of extend class method is special

 self.included
  

Executed when the module is mixed in with include Gives us access to the including class Common Ruby idiom allows us to extend the base class with a new set of class methods here, too
def self.included(base) base.extend(ClassMethods) end

Should I Test My Plugin?

If you’re extracting a plugin, you probably already have tests for a lot of that functionality, right?

Testing Strategies
 Varies

depending on the type of plugin

Mock/stub out your environment if possible
Test the behavior of the system with plugin installed  Rather than the eccentricities of the plugin code itself

For model plugins, consider creating an isolated in-memory database (sqlite3)

 Rake
 

testing tasks already provided

See Rakefile and sample test provided by generator rake test:plugins

Test Helper
# vendor/plugins/isbn_validation/test/test_helper.rb $:.unshift(File.dirname(__FILE__) + '/../lib') RAILS_ROOT = File.dirname(__FILE__) require require require require 'rubygems' 'test/unit' 'active_record' "#{File.dirname(__FILE__)}/../init"

config = YAML::load(IO.read( File.dirname(__FILE__) + '/database.yml')) ActiveRecord::Base.logger = Logger.new( File.dirname(__FILE__) + "/debug.log") ActiveRecord::Base.establish_connection( config[ENV['DB'] || 'sqlite3']) load(File.dirname(__FILE__) + "/schema.rb") if File.exist?( File.dirname(__FILE__) + "/schema.rb")

Dummy Test Models (models.rb)
class Book < ActiveRecord::Base validates_isbn :isbn, :message => 'is too fantastical!' end class Book10 < ActiveRecord::Base set_table_name 'books' validates_isbn :isbn, :with => :isbn10 end class Book13 < ActiveRecord::Base set_table_name 'books' validates_isbn :isbn, :with => :isbn13 end

Unit Testing
require File.dirname(__FILE__) + '/test_helper' require File.dirname(__FILE__) + '/models' class IsbnValidationTest < Test::Unit::TestCase def setup @book = Book.new @book10 = Book10.new end def test_isbn10_should_pass_check_digit_verification @book.isbn = '159059993-4' assert @book.valid? end # ... end

Rspec fan?
 Use

Pat Maddox’s RSpec plugin generator
 Uses

RSpec stubs instead of Test::Unit  Also sets up isolated database for you!
--with-database

 Install

the plugin

ruby script/generate rspec_plugin isbn_validation http://github.com/pat-maddox/rspec-plugin-generator

Distributing Plugins
 Use

a publicly visible Subversion or Git repository.  It’s that easy.
 Options:
  

Google Code (Subversion) RubyForge (Subversion) GitHub (Git) <= Recommended!

ruby script/plugin install \ git://github.com/zapnap/isbn_validation.git

Distributing Plugins as Gems?
 Can
 In

also package plugins as RubyGems
environment.rb:
config.gem “zapnap-isbn_validation”, :source => “http://gems.github.com”, :version => “>= 0.1.1”, :lib => “isbn_validation”
 Then,
 rake

to install it in the project:

gems:install  rake gems:unpack  rake gems:unpack:dependencies

Gem Advantages
 Reasons
 Proper

to prefer Gems for packaging
versioning  Dependency management

 GitHub
 Gems

makes Gem creation easy
will be automatically created for

you  Installable via gems.github.com  Other helpful Gem creation tools: Echoe, Hoe, Newgem

Gemify: GitHub Workflow

Create a Gemspec in the root of your repository
 

Tip: Create a Rake task to generate a Gemspec! Echoe can be handy for this, too

Push updated code to repository  Check RubyGem box on GitHub project edit page

Can now install as either a Gem or a Plugin!

spec = Gem::Specification.new do |s| s.name = %q{isbn_validation} s.version = "0.1.1" s.summary = %q{adds an isbn validation...} s.description = %q{adds an isbn validation...} s.files = FileList['[A-Z]*', '{lib,test}/**/*.rb'] s.require_path = 'lib' s.test_files = Dir[*['test/**/*_test.rb']] s.authors = ["Nick Plante"] s.email = %q{nap@zerosum.org} s.platform = Gem::Platform::RUBY s.add_dependency(%q<activerecord>, [">= 2.1.2"]) end desc "Generate a gemspec file" task :gemspec do File.open("#{spec.name}.gemspec", 'w') do |f| f.write spec.to_ruby end end

What Else?
 Rake

tasks

 Made

available in the host Rails project  Test and rdoc tasks are free
 Generator

plugins  Plugin patterns  alias_method_chain

Conclusion ( PDI )
 Every

plugin will require different strategies for development & testing
Don’t be afraid to read the Rails source

 Fortunately,
  

lots of OSS plugins to look to for examples -- no better way to learn!
http://github.com http://agilewebdevelopment.com/plugins http://railslodge.com - http://railsify.com Good luck & don’t forget to let us know about your new plugin!

Thanks!
Nick Plante.
@zapnap

Partner, Software Developer Ubikorp Internet Services
nap@ubikorp.com http://ubikorp.com nap@zerosum.org http://blog.zerosum.org

http://github.com/zapnap/isbn_validation

Sign up to vote on this title
UsefulNot useful