Content Negotiation And Rails

Let’s get back to REST and Rails. One of the things Rails doesn’t support is HTTP
Content Negotiation
. Why would you want this? Because different clients understand different content types. For example, you can serve XHTML to Firefox but only HTML to Internet Explorer. Or perhaps you are using AJAX, and want to send JavaScript back to the browser. Or maybe another computer system is talking to yours and it would like to have machine parseable XML thank you.

To accomplish this in Rails 1.0 means manually setting the content type. One place to do this is in a controller. But that doesn’t seem right. Controllers help guide a client request from its inception to its fulfillment. A controller should have no knowledge about how the results are rendered. That’s the realm of ActiveView. Now you are talking! But wait…by the time a view is invoked by Rails the content type has already been decided (that’s not quite true, but you sure don’t want to be in an RHTML template generating YAML).

So let’s take a step back. We see that Rails has already started a custom – HTML files are in .rhtml templates, XML files in .rxml, JavaScript in .rjs. So let’s stick with that. Thus, our goal is to implement code that picks the appropriate template based on a client’s stated desires as indicated by the HTTP Accept header.

How do we such a thing? Well, first go read Joe Gregorios’ excellent introduction to the subject and implementation in Python. Then download this Rails plugin that I wrote a few month ago that implements support for Mime Media Types in Ruby and integrates them into Rails.

The key bit of code is the negotiate_content method. It iterates, in order of importance as defined by the client, the different Mime Types supported by the client. For each Mime Type, it sees if there is a corresponding template with the right extension. Once it finds one, the template is loaded and the request is fulfilled:

# Copyright (c) 2006 MapBuzz
# Released under the MIT License
# Author: Charlie Savage

module ActionView
  class Base
    def pick_template_extension(template_path)#:nodoc:
      if match = delegate_template_exists?(template_path)
        match.first
      else
        negotiate_content(template_path)
      end
    end

    def negotiate_content(template_path)
      result = 'rhtml'
      
      if !controller.respond_to?(:request)
        return result 
      end
      
      negotiator = ContentNegotiator.new
          (controller.request.env['HTTP_ACCEPT'])

      # In order of preference, as specified by the client via
      # HTTP_ACCEPT, check to see if we can produce the media type 
      negotiator.media_types.each do |media_type|
        extension = media_type.extension
        if media_type.extension and
          template_exists?(template_path, extension)
          result = extension
          # Break out of this block
          break
        end
      end

      # If all else fails return rhtml
      result
    end
  end
end
    

One thing that is not immediately obvious is that this code will run every time a template or partial is run. We can use this our advantage. For example, let’s say you want to serve HTML to Internet Explorer and XHTML to Firefox.

First, in your layout file do this:

<%= render(:partial => 'layouts/doctype') %>

Next, create one partial for Internet Explorer, called _doctype.rhtml:

<!DOCTYPE HTML PUBLIC 
 "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<% controller.response.headers["Content-Type"] = MediaType::HTML %>

And now one for Firefox called _doctype.rxhtml (make sure to get the extension right!):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC 
 "-//W3C//DTD XHTML 1.0 Strict//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<% controller.response.headers['Content-Type'] = MediaType::XHTML %>

Notice that we are neatly able to reuse 99% of our templates for both browsers, yet at the same time include the <?xml…?> header for Firefox but not Internet Explorer.

Last, its good to see that the Rails team has recently discovered the HTTP Accept header and is adding support in Rails 1.1. I haven’t had a chance to look at their implementation yet since I’m not running Edge Rails. So take the following comment with a grain of salt. Its certainly a good thing for Rails to become more aware of MimeTypes, but as mentioned above, I don’t think controllers are the right place to do this.

There is one caveat though. Mime Types are a blunt instrument (xml/text doesn’t tell you much about a document) and therefore do not provide sufficient information in all cases. The most obvious example is trying to serve the same content type either via a standard browser request or an Ajax request. In the former case you generally want to render a layout and in the later case you do not. Of course, if you are able to make Ajax requests use a different content type (i.e., JavaScript) then there aren’t any issues. Alternatively, you can always implement two different external apis to take care of this case.

Once I get my hands on Rails 1.1 I’ll make sure to update this plugin if it is still useful.

Leave a Reply

Your email address will not be published. Required fields are marked *

Top