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


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

 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

 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?


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

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

 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

 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


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( File.dirname(__FILE__) + '/database.yml')) ActiveRecord::Base.logger = 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 = @book10 = 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!

 Install

the plugin

ruby script/generate rspec_plugin isbn_validation

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://

Distributing Plugins as Gems?
 Can
 In

also package plugins as RubyGems
config.gem “zapnap-isbn_validation”, :source => “”, :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  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 = do |s| = %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"] = %q{} s.platform = Gem::Platform::RUBY s.add_dependency(%q<activerecord>, [">= 2.1.2"]) end desc "Generate a gemspec file" task :gemspec do"#{}.gemspec", 'w') do |f| f.write spec.to_ruby end end

What Else?
 Rake


 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! - Good luck & don’t forget to let us know about your new plugin!

Nick Plante.

Partner, Software Developer Ubikorp Internet Services