Rails 2.1 and Incoming JSON Requests

25 May 2008

Earlier this week, we tried to figure out the cleanest and easiest way to get our Rails app to accept incoming JSON requests. Up until recently, developers were able to use various Rails plugins for this purpose, such as the json_request plugin.

Luckily, it turns out that full support for JSON was added to Rails in April, making it a first class citizen along with XML and regular URL-encoded form fields. This functionality will be officially released in Rails 2.1, but in addition to Edge Rails, it is already included in Rails 2.0.991, which is available from the Ruby on Rails Gem Repository. You can install this pre-release via:

sudo gem update rails --source http://gems.rubyonrails.org

Using this functionality is really simple. Let's say we have created the following scaffolded Rails app with a Book resource, perhaps to manage your library:

rails library
cd library
script/generate scaffold book title:string author:string isbn:string price:decimal
rake db:migrate

As you know, you can now access the books controller in the browser via http://localhost:3000/books, and use the "New Book" link to create a new book via the scaffolded form that Rails provides. But you can also create books via JSON (or XML, for that matter). In fact, we will try XML first, which has been natively supported in Rails for a while:

curl -H "Content-Type:text/xml" -H "Accept:text/xml" \
  -d "<book><title>Posted via XML</title><author>Ex Emel</author><isbn>1234567890</isbn><price>34.99</price></book>" \
  http://localhost:3000/books

Note that I am setting both the Content-Type and Accept header to "text/xml", indicating that the incoming request consists of XML and that we would like to receive an XML-formatted response as well. The response looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<book>
  <author>Ex Emel</author>
  <created-at type="datetime">2008-05-26T05:58:38Z</created-at>
  <id type="integer">2</id>
  <isbn>1234567890</isbn>
  <price type="decimal">34.99</price>
  <title>Posted via XML 2</title>
  <updated-at type="datetime">2008-05-26T05:58:38Z</updated-at>
</book>

Now let's try the same thing in JSON:

curl -H "Content-Type:application/json" -H "Accept:application/json" \
  -d "{\"book\":{\"title\":\"Posted via JSON\", \"author\":\"Jason Bourne\", \"isbn\":1234567890, \"price\":49.95}}" \
  http://localhost:3000/books

As you can verify in the browser, this request was successful and had the desired effect. However, unlike the XML case, there was no response this time. This is because our controller does not know how to render JSON results yet. Looking at the create method in the BooksController, we notice that the responds_to block contains entries for HTML and XML, but not for JSON. Simply copy the XML lines and replace all occurrences of xml with json. The updated method should look like this:

def create
  @book = Book.new(params[:book])
    
  respond_to do |format|
    if @book.save
      flash[:notice] = 'Book was successfully created.'
      format.html { redirect_to(@book) }
      format.xml  { render :xml => @book, :status => :created, :location => @book }
      format.json { render :json => @book, :status => :created, :location => @book }
    else
      format.html { render :action => "new" }
      format.xml  { render :xml => @book.errors, :status => :unprocessable_entity }
      format.json { render :json => @book.errors, :status => :unprocessable_entity }
    end
  end
end

If you run the same curl command again, you should now get the following response:

{"book": {"isbn": 1234567890, "updated_at": "2008-05-26T06:11:33Z",
"title": "Posted via JSON", "price": 49.95, "author": "Jason Bourne",
"id": 4, "created_at": "2008-05-26T06:11:33Z"}}

One important thing to note is that both the incoming and outgoing JSON contain an outermost element called book. This is in fact required for resource based JSON requests to work. The same is true for XML, but since XML requires an enclosing element (unlike JSON, which can be "naked"), it is perhaps less obvious in this case. The outermost JSON element should always have the same name as the resource it corresponds to.

A few notes on testing JSON requests:

We initially banged our heads against the wall trying to figure out how to convince our functional test to pass JSON-formatted parameters in the post method, including various hacks to override different settings on the @request object. Admittedly it had been a while since I had seriously used Rails the last time, but it later dawned on me that we were going about this the wrong way. Functional tests (in Rails, anyways... let's not talk about its confusing and non-standard test terminology) bypass most of the actual HTTP request handling and are not meant to test this aspect of an application. They essentially pick up at the point where the controller has received its (already parsed) parameters in the params hash, regardless of whether these originated from an XML, JSON, or URL-encoded form request.

Since JSON support is implemented by Rails (and thus covered by its own unit tests), it probably does not make sense to focus too much on testing this general functionality in the individual application. But if you do want to test JSON requests, you can use integration tests for this purpose. The post method in integration tests is more low level and simulates the actual HTTP request, along with the parameter parsing.

So in our library example, we might use an integration test case such as the one below to specifically test creating a book via JSON:

def test_create_via_json
  assert_difference('Book.count') do
    post '/books/create',
      '{"book":{"title":"Posted via JSON", "author":"Jason Bourne", "isbn":1234567890, "price":49.95}}',
      {'Content-Type' => 'application/json', 'Accept' => 'application/json'}
  end
end

Native JSON support in Rails is definitely a useful feature. In fact, I was fairly surprised that this wasn't already implemented until recently. But now that it's here, it should come in very handy.

blog comments powered by Disqus