The Complete Guide to Rails Plugins: Part II 11 comments

posted Tuesday, May 9, 2006 by topfunky

(← Part I, for those who missed it.)

Wow! I didn’t realize plugins would be so popular. It’s tempting to name my next post “The Complete Guide to What Geoff Ate For Dinner: Part VII.”

I did have the misfortune of writing a popular article during a spell of mayhem at Dreamhost. Several people blogged about the difficulty of keeping Typo running smoothly at the end of last week. So, I did what I should have done a while ago and switched this blog to a speedy VPS server at Rimuhosting. Shared hosts are still a great place for sites with a small codebase or for sites that can use page caching (where an entire HTML file is written to disk). I still have a few sites at Dreamhost.

As it turns out, I’ll be switching again to a dedicated host in a few weeks. I’ll be sharing it with one or two other Rails developers. So you can expect “The Complete Guide to Shared, Virtual, and Dedicated Hosting in Eighteen Parts” sometime this summer.

Meanwhile…

Today I’ll show you how to package a helper method as a plugin, which will show the basics of making any kind of code-based plugin. I’ll be using a plugin I wrote today based on code from Jeremy Voorhis and Typo. It’s a helper that fills in some missing header tag functionality, such as printing a doctype declaration at the top of your HTML pages or rendering a meta_tag for your site’s description and keywords.

The final project is the meta_tags plugin.

Here are some of the methods it defines:

xhtml_doctype :strict
html_tag :lang => 'zh'
meta_tag 'keywords', 'plugins, rails, tutorials'

The basics steps are:

  • Write a module with your new functionality
  • Write an init.rb that includes your module into the appropriate class
  • Write a test for your new methods

Mix Master, Cut Faster

There are several ways to write code that enhances Rails, but the cleanest way to do it is with a mixin. A mixin is a simple module that defines a few methods. The difference is that a mixin module is basically worthless on its own until it is combined with an existing class. Because it is designed to be combined with an existing class, it can use any of the variables or methods of the class it will be a part of.

When you design a plugin, you have to decide how it will be used:

  • Should developers explicitly request the functionality for the classes that need it? This gives the developer more control, but requires him to add include SomeSpecialModule to the relevant classes. Your plugin will work this way if you leave init.rb blank.
  • Should the functionality work automatically as if it was built-in? Most of the time, you want extra helper methods to be available to all views. For this, you can automatically include the module in your plugin’s init.rb.

Some classes you might want to automatically enhance are:

  • ActionView::Base for helpers. Example: calendar_helper
  • ActiveRecord::Base for models. Example: The newer acts_as_taggable
  • Test::Unit::TestCase for adding your own assertions.
  • ActionController::Base for controller methods (but not full actions). Rarely done, but possible. account_location could be used this way (but is implemented differently).
  • Any other Rails class (for migrations, routes, etc.). Example: restifarian

The meta_tag method

There are several methods in the meta_tags plugin, but the meta_tag is one of the simplest. Here it is:

# Goes in lib/meta_tag_helper.rb
module MetaTagHelper

  # Renders a meta tag for use in the HEAD section of an html document.
  def meta_tag(name, value)
    tag :meta, :name => name, :content => value unless value.blank?
  end

end

Here are a few things to note about this method:

  • It uses the built-in tag helper from Rails. You don’t see the tag method defined in this module because we will mixin to ActionView::Base.
  • The name of the module doesn’t matter. You might want to group your methods under your own name, such as Topfunky::MetaTagHelper. The Java convention is to use your domain name in reverse, but Ruby doesn’t have a convention like that. It’s up to you to choose something unique.

init.rb

I want this method to be automatically available to all my views as if it were built-in. For that, I add one line to init.rb:

# Contents of init.rb
ActionView::Base.send :include, MetaTagHelper

This tells ActionView to call itself with the include method, and to pass my MetaTagHelper module as the argument. All the methods in MetaTagHelper are now part of ActionView and can be called from any view template. That was easy!

In a normal Ruby library I would have to require 'meta_tag_helper', but Rails does this automatically. In fact, I could drop a full model file into the lib folder and it would be picked up by Rails without any extra code. My Mint plugin has several models that work this way.

Testing Your Plugin

A surprisingly large number of plugins have no tests at all. Part of the reason might be that writing a plugin test is a little bit harder than writing a normal unit test. Since a module is worthless on its own, you must add the rest of the functionality for any Rails methods you call in your new helper. If your method doesn’t use any built-in methods, your job is easier.

There are two ways to run the tests for your plugin. First, you can use the Rake task created by the plugin generator. Navigate to your plugin’s directory and type rake. Your tests will be run.

The second way is to test all your plugins from the root of your Rails app. Type rake test:plugins and all tests will be run. NOTE This uses a global test task. Changes to the Rakefile in your plugin’s directory will not affect the behavior of the test:plugins task.

Of course, you need to write some tests first!

A Simple Test

A simple method that doesn’t call any built-in Rails methods can be tested like this:

require 'test/unit'
# Add your module file here
require File.dirname(__FILE__) + '/../lib/meta_tag_helper'

class MetaTagTest < Test::Unit::TestCase

  # Explicitly include the module
  include MetaTagHelper

  def test_end_html_tag
    assert_equal "</html>", end_html_tag
  end

end

The important thing is that you require your helper file and that you include your module.

A More Complicated Test

You may remember that I used the built-in tag helper in my own helper. The steps are the same, but are slightly more complicated since you need to require the relevant file from Rails and also include the relevant helper.

Here’s how I do it:

# Rubygems is where Rails is located
require 'rubygems'
require 'test/unit'
require File.dirname(__FILE__) + '/../lib/meta_tag_helper'
# Here's the helper file we need
require 'action_view/helpers/tag_helper'

class MetaTagTest < Test::Unit::TestCase

  # This is the helper with the 'tag' method
  include ActionView::Helpers::TagHelper
  include MetaTagHelper

  def test_meta_tag
    output = meta_tag('keywords', 'cat, mouse, squirrel')
    assert_equal "<meta content=\"cat, mouse, squirrel\" name=\"keywords\" />", output
  end
end

That’s a little bit of extra work, but it works!

Advanced Testing

Someone asked how to test models that are part of a plugin. The full answer is quite complicated, but here’s are a few options to explore:

Next Time…

Generators and other assorted fun facts.

Ruby on Rails Workshops in New York City and San Francisco
11 comments

Leave a response

  • Good work! On the subject of mocks. Am I correct in thinking that creating a mocks folder etc inside your plugin/tests/ as in a main app does not work?

    Look forward to the Generators guide.

  • You’re right…the Rakefile in your plugin directory could look for that, but it doesn’t. In the meantime, you could add it to the load path (in the Rakefile, with t.libs) and require it in your plugin test.

    The plugin system is well designed, but has a few rough edges like that which I hope to submit a patch for.

  • Gravatar icon Brian Hogan

    This is truly great. I’ve written a few plugins and had to guess at a lot of this. Thanks for clearing this up! I know quite a few people who will find this useful.

  • Gravatar icon Luis Gomez

    If you needed a class shared and automatically included in a bunch of different apps (in the same server), would you do it as a plugin? Even if you want to be able to make changes to the class and have it automatically propagate?

  • Gravatar icon topfunky

    @Luis:

    There are two solutions for that:

    • Write a plugin and install it with ./script/plugin install -x my_plugin. Everytime you update a rails project, it will check for the newest version of the plugin. However, this requires you to update all the apps on the server in order for them to get the benefit.
    • Write a RubyGem. A gem is installed on the server and will instantly be available to all apps that require it. Rails itself is a RubyGem. RubyGems Docs

  • Gravatar icon topfunky

    Also, see Scott Barron’s excellent acts_as_state_machine for how to setup Sqlite as a test database for ActiveRecord-powered plugins.

  • Gravatar icon Jaime

    What is the proper way for the plugin to add migrations? suppose a plugin needs to add a column to an existing table or a new table? Should the init script add migrations to the migrations directory? What about later versions of the plugin and how the interact with previous migrations?

  • Gravatar icon Thomas

    Hi,

    Where is part III? :-)

  • Gravatar icon Brandon

    Although the helper works fine, the test code pukes, failing to successfully include ActionView::Helpers::TagHelper. Here’s the full error message:

    $ rake test:plugins --trace  
    (in /Data/Rails/custom_plugin_exercise)
    ** Invoke test:plugins (first_time)
    ** Invoke environment (first_time)
    ** Execute environment
    ** Execute test:plugins
    /usr/local/bin/ruby -Ilib:test "/usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb" "vendor/plugins/transmogrifier/test/transmogrifier_test.rb" 
    /usr/local/lib/ruby/gems/1.8/gems/actionpack-2.0.2/lib/action_view/helpers/tag_helper.rb:11: uninitialized constant ActionView::Helpers::TagHelper::Set (NameError)
            from /usr/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:32:in `gem_original_require'
            from /usr/local/lib/ruby/site_ruby/1.8/rubygems/custom_require.rb:32:in `require'
            from ./vendor/plugins/transmogrifier/test/transmogrifier_test.rb:7
            from /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb:5:in `load'
            from /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb:5
            from /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb:5:in `each'
            from /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb:5
    rake aborted!
    Command failed with status (1): [/usr/local/bin/ruby -Ilib:test "/usr/local...]
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:899:in `sh'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:906:in `call'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:906:in `sh'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:985:in `sh'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:920:in `ruby'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:985:in `ruby'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/testtask.rb:117:in `define'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1003:in `verbose'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake/testtask.rb:102:in `define'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:546:in `call'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:546:in `execute'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:541:in `each'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:541:in `execute'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:508:in `invoke_with_call_chain'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:501:in `synchronize'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:501:in `invoke_with_call_chain'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:494:in `invoke'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1931:in `invoke_task'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1909:in `top_level'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1909:in `each'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1909:in `top_level'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1948:in `standard_exception_handling'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1903:in `top_level'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1881:in `run'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1948:in `standard_exception_handling'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/lib/rake.rb:1878:in `run'
    /usr/local/lib/ruby/gems/1.8/gems/rake-0.8.1/bin/rake:31
    /usr/local/bin/rake:19:in `load'
    /usr/local/bin/rake:19
    

  • Hi webmaster!

  • Hi webmaster!

Your Comment

Nuby on Rails

Geoffrey Grosenbach / Ruby / Code / Graphics / Design / Rails / Merb / Javascript / CSS

Ads by The Lounge

Manufactured with

Subscribe

Subscribe (RSS)