Wednesday, February 13, 2008

MIME responder filter for Rails

I didn't think I had to do this, but I ended up writing a filter that acts like a switch statement for different MIME types. Let me explain. Normally, in Rails, you can respond to different requests for different content with something like this:

class PostController < ActionController::Base
def list
@posts = Post.find(:all)
respond_to do |format|
format.html
format.fbml
format.xml { render :xml => @people.to_xml }
end
end
end

When you have something like this, the browser (or whatever client) can ask for different MIME types. Here, we can return html to a browser, xml to maybe a data importer, and fbml to facebook.

I spent last week integrating Mobtropolis with facebook. Mobtropolis doesn't require a facebook account to use it, so like other websites, it has its own authentication mechanism, something like:

class PostController < ActionController::Base
before_filter :website_authenticate_filter, :except => [:index, :list]
end

When I started using facebooker library, it already came with an authentication before_filter. That means we have two authentication filters, one native, and one for facebook. Mobtropolis users don't have to be in facebook to use it, and facebookers don't have to sign up again in mobtropolis to use it.

However, since before_filters are executed in succession, it leads to a case where the facebook authentication would be called if html was requested, and vice versa. The alternative was to take apart both authentication filters, and create a monolithic filter to handle the two different cases. Instead, I did this:

class PostController < ActionController::Base
before_respond_to_filter :except => [ :index, :list ] do |format|
format.html :website_authentication_filter
format.fbml :facebook_authentication_filter
end
end

That way, I didn't have to mix together the guts of each authentication filter, and it solved the problem of the wrong authentication filter being run. You can also use it like:

class PostController < ActionController::Base
before_responds_to_filter :only => :home do |format|
format.html do |controller|
return if controller.logged_in?
controller.send(:redirect_to, :controller => :home)
end
format.fbml :ensure_application_is_installed_by_facebook_user
end
end

By the way, I tried to alter the filter_chain as a request came in. Filter chains are copied and passed around the filters, so you can't write a filter that alters the filter chain. So don't waste your time crawling around in the guts of Rails to do this like I did. It's just as well, as that'd be a nightmare to maintain.

It does have some weaknesses though. You can only assign the filters to the same set of :except and :only options in the filters.

It ended up the code for this sort of magic was fairly easy. I'm not sure if there's an easier way to do what I wanted, but I'll see if Rails core people would find it useful (or not). In the meanwhile, for those of you Rubyists that have written plugins before that want to play with it. As with the usual mumbo jumbo, it's provided as is, I'm not maintaining it, and do whatever you want with it:


module Threecglabs
module Filters

# MimeResponderFilter
module MimeResponderFilter

def self.included(mod)
mod.extend(ClassMethods)
end

# Filters can respond to different mime types, so that you can use
# different filters depending on which mime type is being requested
#
# before_responds_to_filter :except => [:login, :signup, :forgot, :invite_request, :profile] do |format|
# format.html :authentication_filter
# format.fbml :ensure_application_is_installed_by_facebook_user
# end
#
# This way, one can take the appropriate actions in setting up authentication
# from different mime types, and still separate the implemenation of the different
# kinds of implementations
#
# The formats also take blocks, like regular filters
#
# before_responds_to_filter :only => :home do |format|
# format.html do |controller|
# return if controller.logged_in?
# controller.send(:redirect_to, :controller => :home)
# end
# format.fbml :ensure_application_is_installed_by_facebook_user
# end
#
# NOTE: an :all format defaults to :html, therefore, a format.html is required
module ClassMethods
def before_respond_to_filter(options = {}, &block)
before_filter MimeResponderFilter.new(&block), options
end

private
# This is a call that implements a MIME responder filter
class MimeResponderFilter#:nodoc:
attr_reader :filters

def initialize(&block)
@filters = {}
block.call(self)
end

def filter(controller)
filter = @filters[controller.request.format.to_sym] || @filters[:html]
if filter.kind_of?(Proc)
filter.call(controller)
else
controller.send!(filter)
end
end

# implements the "format.#{mime_type}" part of the filter
def method_missing(mime_type, method_name = nil, &block)
if block_given?
@filters[mime_type.to_sym] = block
else
@filters[mime_type.to_sym] = method_name.to_sym
end
end
end
end

end
end
end


Snippet!

7 comments:

  1. Anonymous9:24 AM

    Very useful - thanks so much for this. There seems to be a problem in Rails 2.2.0 RC, though.
    "undefined method `send!'"
    "lib/mime_responder_filter.rb:51:in `filter'"

    ReplyDelete
  2. huh, ok. I've been meaning to pull it out as a plugin, so this gives me an excuse to do it this weekend.

    I haven't upgraded to rails 2.2.0 yet since it has a bunch of bugs with various plugins.

    ReplyDelete
  3. Anonymous4:22 PM

    I would like to use this code in my webapp. How should I install it? I guess that I need to put it in the lib directory, but I am sure I am doing something wrong.

    ReplyDelete
  4. I should mention that this should be deprecated, as there's no need for the current version of rails. Instead, use:

    get :list, :format => :fbml

    If you've registered fbml to Mime::Types

    ReplyDelete
  5. Anonymous12:23 PM

    Thank you for your answer, but... could you give me a little more context?

    I want to run an :authorize filter before any action in the controller, except when the action is "index" and the format is "js". I unsuccessfully tried to do this:

    before_filter :authorize, :except => get(:index,:format=>:js)

    ReplyDelete
  6. Oh, I thought you were talking about calling an HTTP method in your tests. That's what my last comment was about. My bad.

    As far as I'm aware (I'm a little behind edge rails), you you can't use the before_filter like that.

    You'd need to cut and paste the code into a file (probably named mime_filter.rb) in your lib directory. I haven't kept it up, so you'll need to do any debugging that you need for it.

    I would suggest that you rethink your code so that you don't need to do this, as I'd consider it a code smell. Usually, this means that you're doing some sort of overloading of a method, where different people with different permissions need to do the same thing.

    Keeping a controller method for one authorization level will keep your code more clean and readable, imho.

    If there's common functionality between the controllers, push it down into the models. If it doesn't belong in the model and is business logic, refactor it into a private method in the controller, or into a separate module you mix into the controller when there's a bunch of related private methods.

    In hindsight, I'd say this is probably a mistake for an anomalous case that I did to bend it to use facebook.

    ReplyDelete
  7. Anonymous4:46 PM

    Thank you for your exteded answer. I really did not want to split my controllers.
    The solution was not so complicated:

    before_filter :require_admin, :except => :index

    before_filter(:only => :index) do |controller|
    controller.send(:require_admin) unless controller.request.format.js?
    end

    This was helpful:
    http://guides.rubyonrails.org/action_controller_overview.html#filters

    ReplyDelete