respond_to :iPhone in Rails

Posted by Marc Love
on Friday, June 29

As promised in my prior article, here is my current solution to quickly and easily tack on an iPhone specific user interface to Rails applications. Our goal is to be able to deliver customized html, js and css for the iPhone. To do so, I’ve decided to hijack our best friend respond_to. respond_to delivers different content to browsers based on their HTTP request and even though its only designed to discriminate based on mime-type, its pretty easy to modify it to also discriminate based on user-agent. The other distinct advantage of using respond_to is that not only can we create different html, javascript and css views, but we can also pass blocks.

If you want to follow along in the core, open up actionpack/lib/action_controller/mime_responds.rb and scroll down to the Responder Class.

The initialize method checks the CGI request for the list of acceptable formats and puts them in an array in order of preference (@mime_type_priority). Later it iterates through this array and returns the first one that matches what’s listed in respond_to. Let’s modify it to automagically add iPhone specific MIME types to the front of that preference array.

alias original_initialize initialize

def initialize(controller)
  original_initialize(controller)
  # If the user agent is Safari on iPhone, duplicate the acceptable
  # formats with iphone_ prepended versions
  if @request.user_agent.include?('iPhone')
    # We only need to duplicate html, js & css mime-types
    mime_types_to_duplicate = @mime_type_priority.select do |m|
      [Mime::EXTENSION_LOOKUP['html'], Mime::EXTENSION_LOOKUP['js'], Mime::EXTENSION_LOOKUP['css']].include?(m)
    end
    # Retrieve iPhone mime-types
    mime_types_to_duplicate.map!{ |m| Mime::EXTENSION_LOOKUP[("iphone_" + m.to_sym.to_s)] }
    # and place them first in the priority list.  If they exist, they'll
    # be delivered first.  If they don't, then iPhone requests will fallback
    # on the standard html/js/css responses.
    @mime_type_priority =  mime_types_to_duplicate + @mime_type_priority
  end
end
When respond_to is called, it creates a new Responder object and calls it’s respond method. Eventually method_missing is called on the mime_type names which sends those names on to the custom method. custom generates a response for that mime-type. We have to grab these iPhone mime-types before they are processed by custom, otherwise we’ll end up sending a response to the browser with an invalid content-type header for our fake iPhone mime-types. So let’s change method_missing to this:
def method_missing(symbol, &block)
  mime_constant = symbol.to_s.upcase

  # Intercept iphone_ prepended acceptable mime-types and send them to our
  # custom_iphone response constructor
  if mime_constant.include?('IPHONE_') && Mime::SET.include?(Mime.const_get(mime_constant))
    custom_iphone(Mime.const_get(mime_constant), &block)
  elsif Mime::SET.include?(Mime.const_get(mime_constant))
    custom(Mime.const_get(mime_constant), &block)
  else
    super
  end
end

Now we need to create our custom_iphone method which creates the response just like custom except it changes the content-type header to the valid content-type:

def custom_iphone(mime_type, &block)
  mime_type = mime_type.is_a?(Mime::Type) ? mime_type : Mime::Type.lookup(mime_type.to_s)
  # Remove the "iphone_" part of the name and retrieve the valid mime-type object
  actual_mime_type = Mime::Type.lookup_by_extension(mime_type.to_sym.to_s[7..-1])

  @order << mime_type

  if block_given?
    @responses[mime_type] = Proc.new do
      @response.template.template_format = mime_type.to_sym
      # Deliver the valid mime-type instead of our fake iphone one
      @response.content_type = actual_mime_type.to_s
      block.call
    end
  else
    @responses[mime_type] = Proc.new do
      @response.template.template_format = mime_type.to_sym
      # Deliver the valid mime-type instead of our fake iphone one
      @response.content_type = actual_mime_type.to_s
      @controller.send :render, :action => @controller.action_name
    end
  end
end
One last thing. We need to register our fake iPhone MIME types:
Mime::Type.register "iphone/html", :iphone_html
Mime::Type.register "iphone/javascript", :iphone_js
Mime::Type.register "iphone/css", :iphone_css
A little hacky? Yea. I’m not so happy with creating fake MIME types and respond_to wasn’t meant to identify user-agents, but it’ll work until I can figure out a cleaner implementation. There’s no denying the benefits of harnessing the power of respond_to. It makes developing a separate UI for iPhones super simple. Now you can do things like:
respond_to do |format|
  format.html
  format.iphone_html
  format.js
  format.iphone_js
end
Want to redirect iPhone users away from a particular method?:
respond_to do |format|
  format.html
  format.iphone_html{ redirect_to( :action => "index") }
end

No need to modify any of your other application code or existing views. Just add the .iphone_* templates (i.e. index.iphone_html.erb, index.iphone_js.erb) and modify your respond_to blocks. GOTCHA ALERT: regular and iPhone-specific html templates will share the same layout if one is designated at the controller level. To hide parts of your layout files from iPhone requests, just do:

primary_layout.html.erb:
<% unless request.user_agent.include('iPhone') %>
Hidden from iPhones
<% end %>
Comments

Leave a response

  1. Cuong TranAugust 19, 2007 @ 12:03 AM

    What do you think about my approach, http://cuongt.blogspot.com/2007/08/respondto-iphone-in-rails.html?

Comment