This post originated from an RSS feed registered with Ruby Buzz
by David Heinemeier Hansson.
Original Post: Discovering HTTP #1: The Accept header
Feed Title: Loud Thinking
Feed URL: http://feeds.feedburner.com/LoudThinking
Feed Description: All about the full-stack, web-framework Rails for Ruby and on putting it to good effect with Basecamp
Ruby on Rails is all about eliminating repetition through convention over configuration and any other DRY-hack we can come up with. I believe we've been able to do a great job at institutionalizing that approach with the domain model. Very little repetition throughout the layers. From schema introspection to schema migrations to views aware of errors and controllers aware of parameter-to-column mapping.
But what about the controller itself. How DRY are we in handling different clients desiring different output from the same controller logic? Up until recently, not very. At least not in an institutionalized form.
It's those hacks and ideas that are now able to blossom through the (re-)discovery of the wonderful Accept header in the HTTP specification. Let me show you the code.
class CommentController < ActionController::Base
def create
@comment = Comment.create(params[:comment])
respond_to do |type|
type.html { redirect_to :action => "index" }
type.js
type.xml do
headers["Location"] =
url_for(:action => "show", :id => @comment.id)
render(:nothing, :status => "201 Created")
end
end
end
end
This is a sample controller for adding comments to a weblog. It's able to serve old browsers, Ajaxified browsers, and API access for the blog. Three clients, same controller logic.
If an old browser submits this form, we'll have it done through a plain old POST, which the browser sends along with a header like "Accept: */*". That means "I don't care what kind of response you give me, just give me something". Since the browser doesn't care, we'll decide what to do on the order of the type declarations. The first is type.html, so that's what we'll perform, which in this case simply instructs the browser to go back to the index.
If an Ajax-capable browser submits this form, Prototype will intercept the submission and turn it into an Ajax call. This call will be send along with "Accept: text/javascript; text/html; text/xml; */*", which specifies a preference order where Javascript is first, if not available, then HTML, if not, then XML, and finally it'll accept whatever if none of the preferred forms are available.
Notice how the Prototype preference order doesn't match the order of declarations in the respond_to call. That order was significant when */* was used as the Accept header, but when preferences are available, we';; do multiple passes through the declaration in search of a match. The order of declarations won't matter unless neither Javascript, HTML, nor XML is found.
But in this case, the primary preference of Javascript is available, so that's what we'll send back. The declaration doesn't specify a block describing the behavior that should occur when its triggered, so we'll rely on the default for that type, which is to render :action => "create.rjs".
Finally, if we want to create a comment through the API, we'll make a request that looks like this:
First, notice that we're submitting the comment in XML. Because we're using the Content-Type header of application/xml, Rails will automatically translate the XML body into "comment[body]=First%20post!!&comment[author]=David", which is exactly the same format that the regular form is producing whether its coming from the old-browser POST or an Ajax call. So we don't have to modify the first line of our create action to accommodate the different clients on the input. They all look the same.
Second, notice that we're being explicit about the Accept header. Where we in the Content-Type header were saying "XML is what we give", the Accept header is saying "XML is what we want". This will trigger the type.xml definition, which will produce a response something akin to:
HTTP/1.1 201 Created
Content-Length: 0
Location: http://example.org/comments/show/5
But who's using Accept?
Now this is of course all wonderful, but who actually uses the Accept header? Not a lot, but enough to make it worth it, and you can fake the rest.
Browsers specify "*/*", which is fine. It means that we can use the order of the respond_to definition to give them what we want (usually HMTL). Prototype specifies a detailed set of preferences, but you'll usually just use type.js to send RJS. With those two alone, you now have a very nice way of using the same actions for regular POSTs and Ajax calls.
And if you control your API (like we do at 37signals), you can stipulate that the clients must specify the Accept header if they want XML back. So that makes three.
If your feeds are routed to the same action and you have a before_filter that converts requests to ".rss" into "Accept: application/rss+xml" and ".atom" into "Accept: application/atom+xml", then you can have an index action that supports five outputs off the same data:
class WeblogController < ActionController::Base
def index
@entries = Entry.find :all
respond_to do |type|
type.html
type.js
type.xml(@entries.to_xml)
type.rss { render :action => "rss.rxml" }
type.atom { render :action => "atom.rxml" }
end
end
end
Bake in the assumption that if the request doesn't specify an Accept header, they'll prefer to get back what they supplied, or whatever you have if that's not available, and you don't even need to specify the Accept header for most actions. Talking XML to me? You'll get XML back.
Going forward, I naturally hope that all HTTP clients start using the Accept header by themselves. NetNewsWire could send "Accept: application/atom+xml; application/rss+xml" to indicate that it prefers Atom, but will take RSS if you have it.
Mixing and matching
The cool thing about separating what you give and what you want into two concerns is that you can make your API even easier to use for normal people. With the CommentController above, I could make a call like this:
I talk query data, but get XML back. Or you could have a Ruby script that sends in XML, but specifics "Accept: application/x-yaml", and it'll trigger any type.yaml definition you might have defined. Lots of possibilities.
Nifty, nifty, but why?!
If your controller logic is just 1 line as in both examples above, it's not apparent how big the savings are. But controller logic is often 2, 3, 5, or even 10 lines of code. Do you really want to duplicate that to 2, 3, or 5 different actions to serve that many clients? I hope you don't. And that's exactly what we did doing the Backpack API, so we hacked our way out of it. This tech is the clean version.
But bear in mind that you'll never have complete mapping between all the clients. This is not about creating a 100% solution for all cases across all clients. Some clients will need actions that others don't. Some times the logic just isn't the same even if the action is similar. It doesn't matter, though. If you can get 80% of an API for your application out of the box almost for free, you're much more likely to finish the last 20%, and actually launch with one instead of 2 years after (as is the case with the Basecamp API we're about to introduce).
All this and much, much more will be part of the forthcoming Rails 1.1 release. It's also available today in Edge Rails and using Beta Gems, but we may polish a tad before release.
P.S.: A huge thanks to the team behind the Atompub specification. This document has been a massive eye opener to best practice use of HTTP in a RESTful fashion. I encourage everyone with an interest in web services to read it. It started an avalanche of ideas in my mind. This respond_to implementation is just one offspring of those ideas.