Making Rails Better – Content Negotiation

Over a year ago, I released a Rails 1.0 plugin that added support for content negotiation. Conceptually it was simple – map the HTTP Accept header to template extensions.

For example, assume you have an article controller. A client may wish to GET the articles in various formats, including HTML, XHTML, RSS or ATOM. Thus your views would be:

  • article.rhtml
  • article.ratom
  • article.rjson

The extension is based on the mime type – not the template type. So article.ratom may be an ERB template, a HAML template, a builder template, etc. When the plugin compiles the template it is smart enough to tell what type of template it is, and acts accordingly.

We’ve found this solution works extremely well for MapBuzz, so we’re releasing it as a Rails 1.2 plugin to encourage discussion in the community and hopefully influence its direction. And if you need to support XHTML with your Rails application you’re in luck – the plugin has XHTML support baked in as explained below.

Rails Offers an Unsatisfying Solution

Now you might be thinking to yourself that Rails 1.1 solved this issue. Rails 1.1 did indeed add support for content negotiation by honoring the HTTP Accept header, adding a new format parameter and implementing a new controller method respond_to.

However, I think the implementation leaves much to be desired. Let’s take a look at an example:

class ArticleController < ApplicationController
  def get
    @articles = Article.find(:all)
    respond_to do |wants|
      wants.html
      wants.xml { render :xml => @articles.to_xml }
    end
  end  
end

The first problem is the implementation’s verbosity. You have to add the same boilerplate code for each method in each controller that supports multiple mime types (for MapBuzz that is almost all of them). For opinionated software, this seems like a strange oversight and I’ve always found it jarring.

The second problem is that the implementation mixes view logic into a controller. Why should a controller have knowledge, or care, about how its results are rendered? I can’t see any good reason for it.

On the bright side, it looks like Rails 2 will change this implementation a bit. I’ve recently noticed some blog posts that mention the preferred template naming convention has changed to include both a mime type and template type, thus something like this – article.html.erb. Hopefully that means article.atom.erb, article.rss.xml, etc. will also work but I haven’t checked.

Will these changes make our plugin obsolete? I certainly hope so, but I haven’t had the time to dig into Rails edge to see for sure.

Why Content Negotiation?

Before diving into the plugin, you may wondering why bother – isn’t it generally accepted that content negotiation is a failure? In the “old” Web I’d agree – and much of the blame has to fall on IE 6 for its use of this HTTP Accept header:

Accept: */*

Hmm, thanks Microsoft, very helpful.

But in the world of Ajax, things have changed. XmlHttpRequest lets you set HTTP headers, so a client can specify exactly what type of response it wants. Sometimes Atom is the best choice, sometimes JSON is and other times good old HTML fits the bill. Whichever you choose, when you create an Ajax-based website you control how the HTTP Accept will be set by the client, and therefore content negotiation all of a sudden becomes interesting again.

Using The Plugin

The plugin makes the simple case easy – rename your views based on their mime type:

  • article.rhtml
  • article.ratom
  • article.rjson

Partials also work the same way:

  • _article.rhtml
  • _article.ratom
  • _article.rjson

And if you are using a layout, then the same drill applies:

  • layout.rhtml
  • layout.ratom
  • layout.rjson

The plugin also supports mixing mime types. For example, you may wish to return an XHTML document that includes embedded SVG. To do that, in your enclosing .rhtmltemplate you would include this line:

<%= render(:file => 'article/get.rsvg') %>

By specifying the extension, .rsvg, you’ve alerted the plugin that you want to change the current mime type to SVG. Any templates or partials that get.rsvg in turn calls will be assumed to have an .rsvg extension unless you specifically override it again. Once get.rsvg is finished rendering, the current mime type will revert back to XHTML.

The Plugins Inner Workings

Now let’s look at how the plugin works – it uses this algorithm to render the first template in a given request:

  1. Get a list of potential mime types:
    • If the request includes a format parameter use it.
    • Otherwise, create an array of mime types based on the HTTP accept header. Then modify the
      array by:

      • Prefer XHTML over HTML (see below).
      • If the client supports Atom, then make sure both the Atom Feed format and Atom Entry format are included in the list.
      • If the Accepts header includes */* (or various derivatives seen out in the wild such as *.*, *, etc.), replace it with HTML, ATOM and JSON.
  2. Loop over the list of mime types and search for a template with the correct extension. For example, if the list of mime types is HTML, ATOM and JSON, then the plugin will look for a template with an extension of .rhtml, .ratom or .rjson in that order.
  3. If a template is found, save the current mime type onto a stack. If a template is not found, raise an exception.

Once a mime type is chosen, the plugin will continue to use it for all other templates including partials and layouts. Thus if current mime type is ATOM and the current template calls a partial called called author, the plugin will look for a template named _author.ratom. If it can’t find it, it will raise an exception.

There are two special cases. The first one was explained above, you can switch mime types in mid-stream if needed. The second one is when an exception is raised while rendering a template. In that case, the plugin will “forget” the current format and then look for an appropriate template (thus going back to the long algorithm above).

XHTML Support

Another benefit of the plugin is that it adds full XHTML support to Rails. I’ve previously blogged about Rail’s utter disregard for XHTML, but for MapBuzz its crucial because we have to embed SVG in XHTML files.

So if a browser says it supports XHTML via the Accept header (pretty much any browser other than IE), then the plugin will automatically select XHTML over HTML and set the content type to application/xhtml+xml.

However, there are a couple of twists. First, XTHML is mapped to templates with an extension of either .rxhtml or .rhtml extension. The reason for reusing .rthml is to avoid duplicating them and violating the DRY principle. So make sure that your .rhtml files are valid XHTML. If they are not, you’ll see parse errors in Firefox/Opera/Safari, since they will be applying XHTML’s strict syntax rules.

Second, you have to make sure to specify the correct doctype. The way we solve this is by having our layout.rhtml file call a partial called doctype. There are two versions of this partial – _doctype.rhtml and _doctype.rxhtml.

_doctype.rhtml looks like this:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>

While _doctype.rxhtml looks like this:

<?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" xmlns:xhtml="http://www.w3.org/1999/xhtml" xml:lang="en">

The plugin will pick the correct one based on the current mime type.

Finally, the plugin overrides ActionView::Helpers::TagHelpers so that it correctly ends empty tags with />, as required by XHTML/XML syntax rules, if the current content type is XHTML.

Wrapping Up

As the Rails core team is fond of saying, Rails is opinionated software. In my opinion, mapping mime types to template extensions is a big win:

  • It eliminates boring, boilerplate code
  • It more cleanly separates controllers from views
  • It makes it easy to add a new format to a controller action (just drop in a new template)

But of course the real test is how well does the plugin work in a production website? From our experience it works great – so give it a try and let us know what you think!

Leave a Reply

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

Top