Menu

Putting REST on Rails

April 19, 2006

Dan Kubb

RESTful Dispatching in Rails

"Convention over configuration" is one of the key principles behind the design of Ruby on Rails. It was built with a specific way of doing things and as long as you follow the "Rails way" everything will just work with little or no configuration. That's part of the reason Rails is so successful--as long as you follow its conventions you get many benefits with less work than other frameworks that require you to be more explicit.

The HTTP protocol is very similar in this respect. Stray from the path and you'll find yourself hacking together replacements for basic things HTTP gives you for free. Stay on the path and you gain benefits like caching and better scalability of your applications with less effort. A well-designed--which typically means a RESTful--web application makes good use of available HTTP methods, rather than relying on GET requests to trigger server state changes.

The ideal is to dispatch to a different handler based on the HTTP method, actually running different code depending on the method, and making it impossible to unintentionally change state with a GET request.

RESTful Rails

Chances are, most developers aren't thinking about HTTP simply because it isn't something that's been talked about by mainstream developers until recently. It's pretty common to be a developer and not know much about REST and HTTP, or know why it's important. But what if conventions were established in Rails that gradually steer developers onto a path that makes RESTful applications easier to develop?

With that in mind I set out to develop the RESTful Rails plugin that would make REST applications easier to create in Rails. The project was inspired by the article On HTTP Abuse that outlines features in HTTP that most frameworks don't completely support. My primary goal is to round out support for features that aren't already covered in Rails, while requiring only minimal changes to the way Rails applications are currently being developed.

What You'll Learn

This article introduces a very simple application that uses the RESTful Rails plugin. It will provide an introduction to dispatching to different handlers based on the HTTP method used in a request. If you plan on following along you should already have the latest version of Rails installed (1.1.1 at the time of writing), along with a database of your choice. I will also assume you have basic knowledge about how to configure your database for Rails using config/database.yml. You should also have a Subversion client installed since we'll be using it to install the RESTful Rails plugin.

If you're not sure, here are a few excellent tutorials to get you started:

If you already have Rails installed you can upgrade to the latest version of Rails with the following command:

gem install rails --include-dependencies

You should have at least Rails 1.1.1 to follow along with this article, since some of the features we'll be using were not introduced until very recently.

Getting Started

Let's start off by describing the example we'll be using, a simple app to track a personal library of books. To start the Rails project, run the following from the command line:

rails --database=mysql library

Please note that the --database=mysql switch tells Rails to preconfigure config/database.yml for a MySQL database. Alternatively you can use any of the following options if you prefer:

  • oracle
  • postgresql
  • sqlite2
  • sqlite3

We then move into the library directory that was just created for this project:

cd library

The Model

To keep this example focused on the REST bits, we'll create a Book model with only two attributes (a title and a description) and we'll include no validation or error checking code, which you'd obviously add to a real app.

To create a Book model, type the following at the command line:

ruby script/generate model Book

Test the new model by running its unit tests:

rake test:units

You should see several errors. That's because we need to tell Rails how to connect to the database.

Configuring the Database

I'm using MySQL, but you can use any database that Rails supports to follow along. Open config/database.yml and change it to work with your database configuration.

In my case, config/database.yml now looks like this:

development:

  adapter: mysql

  database: library_development

  username: root

  password:

  host: localhost

  socket: /usr/local/mysql/run/mysql_socket



test:

  adapter: mysql

  database: library_test

  username: root

  password:

  host: localhost

  socket: /usr/local/mysql/run/mysql_socket



production:

  adapter: mysql

  database: library_production

  username: root

  password:

  host: localhost

  socket: /usr/local/mysql/run/mysql_socket

Keep in mind that I'm running this example on a test database server on a local machine. In a production system you would not want to use the root user account. The only requirement for Rails is that the user account needs to be able to CREATE and DROP tables and indexes in addition to the standard SQL commands: SELECT, INSERT, UPDATE, and DELETE.

Also make sure that you've created the databases named in config/database.yml using the approach specific to your database. In my case I ran the following commands to create the databases:

mysqladmin -u root create library_development

mysqladmin -u root create library_test

mysqladmin -u root create library_production

rake test:units should still fail because we haven't created the table for the books yet.

Database Migrations

We need to add the books table to the development database. To do this we'll use the Rails migration system which allows you to specify a schema in a database-independent way. When you run the migration the system will check to the current version of your database schema and bring it up to the most current version.

As of Rails 1.1, whenever you create a new model a migration script will be automatically created for it. In our case the migration script for Book is db/migrate/001_create_books.rb. Update this file to look like the following:

class CreateBooks < ActiveRecord::Migration

  def self.up

    create_table :books do |t|

      t.column :title, :string, :limit => 30, :null => false

      t.column :description, :text

    end

  end



  def self.down

    drop_table :books

  end

end

This will add the books table with two columns: title and description.

Let's run the migration now:

rake db:migrate

This will update the development database in config/database.yml and add the books table.

rake test:units should now pass!

For the remainder of this article we will be in either an "all-tests passing state" or a "failing state." We'll cycle back and forth between these two states as the application begins to take shape.

Testing with Fixtures

Before we dive into the controller we're going to need some data for our tests to work with. We'll use the Rails fixture system to load records into our test database each time a unit test or functional test is run.

The first thing we'll do is create a unit test to ensure the fixture has the information we expect in it. Replace the default test_truth method in test/unit/book_test.rb with the following:

def test_fixture

  assert_kind_of Book, books(:http_book)

  assert_valid books(:http_book)

  assert_equal 1, Book.count

end

This tests that the fixture has a valid :http_book record and contains only one entry. If for some reason the :http_book wasn't found, or more records are added to the the fixture, the unit test will fail.

rake test:units should fail because we haven't configured the fixture yet.

Rails automatically created the fixture test/fixtures/books.yml when our Book model was created. Update this file to look like the following:

http_book:

  id:          1

  title:       "HTTP: The Definitive Guide"

  description: "Everything that technical people need for using HTTP efficiently."

rake test:units should now pass.

We can be fairly confident that the Book model and its fixture are going to work, so we'll move on to the controller, but first we need to install the RESTful Rails plugin.

The RESTful Rails Plugin

The RESTful Rails plugin adds several capabilities to normal Rails controllers that allow them to more effectively use HTTP. The focus of our example will be its most obvious feature, the per-HTTP method dispatching system. With it you will be able to run different code depending on the HTTP request method. A nice extra benefit is that it also takes care of handling the OPTIONS method, which will come in handy in our tests.

To install the plugin, type the following from the command line:


svn co svn://rubyforge.org/var/svn/restful-rails/trunk vendor/plugins/restful-rails

Or if your project is under version control with Subversion already, install it using the Rails plugin system instead:


ruby script/plugin install -x svn://rubyforge.org/var/svn/restful-rails/trunk

Both of these commands will retrieve the latest snapshot of the RESTful Rails plugin and place it in the directory vendor/plugins/restful-rails/.

Now that the plugin is installed, let's create the controller.

The Controller

To create the BookController controller, run the following command:

ruby script/generate controller Book

Test the new controller by running its functional tests:

rake test:functionals

This should pass.

Restful Routes

The BookController controller is going to handle requests for two URIs:

/books/ A collection of books
/books/{id} A book identified by an ID

Let's test that the URIs are being handled properly.

Replace the default test_truth method in test/functional/book_controller_test.rb with the following:

def test_routing

  with_options :controller => 'book' do |test|

    test.assert_routing 'books',   :action => 'collection'

    test.assert_routing 'books/1', :action => 'by_id', :id => '1'

  end

end

This tests that the URIs route to the correct controller and action. Note the use of the new with_options method which saves you some typing by allowing you to specify the default options supplied to all the methods in the block.

rake test:functionals should fail because we haven't set up how URIs are routed to controllers yet.

To set up the routes we need to add a line to config/routes.rb inside the ActionController::Routing::Routes.draw block:

map.connect_resource :book

The connect_resource method is from the RESTful Rails plugin and it will set up routes that match our expected URI structure.

rake test:functionals should now pass.

Now that the routes are set up, we'll move on to the controller.

Making a RESTful Controller

At this point our controller doesn't have any knowledge of the RESTful Rails plugin. To add method dispatching and other features, you need to include RestController::Base at the top of app/controllers/book_controller.rb so it becomes:

class BookController < ApplicationController

  include RestController::Base

end

We're now ready to add the :collection resource to the controller.

The collection Resource

The :collection resource represents a list of books. We want to be able to view the list with the GET method, and add to the list using POST. Let's add a test to test/functional/book_controller_test.rb that ensures these methods are handled:

def test_options_collection

  options :collection

  assert_response HTTP::Status::NO_CONTENT

  assert_no_entity

  assert_allowed_methods :get, :post

end

This tests that when an OPTIONS request is performed on the :collection, it should return a 204 No Content status code. Also the Allow header should identify GET and POST as allowed methods. The options, assert_no_entity and assert_allowed_methods methods are from the RESTful Rails plugin.

rake test:functionals should fail because there is no :collection resource in the controller yet.

First we'll stub out the :collection resource to make the functional tests pass for now. Add the following to app/controllers/book_controller.rb:

resource :collection do |r|

  r.post do

  end

end

rake test:functionals should now pass.

We only specified a stub handler for POST, but didn't say anything about GET. A RESTful Rails resource will handle GET just like a normal Rails action: if you don't use redirect or render, a template with the same name as the resource (if available) will be displayed.

Fixtures

We're almost at the point where we need test data to work with. We'll be reusing the Books fixture we created when testing our model. Simply add the following line above the setup method in test/functional/book_controller_test.rb:

fixtures :books
View the List of Books

Even though GET requests are handled automatically we still need to test what will happen when a GET on :collection is done. In our case we want the collection template to be loaded and display a list of all the books in the database in XML format. Let's add a test to test/functional/book_controller_test.rb that ensures this:

def test_get_collection

  get :collection

  assert_response HTTP::Status::OK

  assert_template 'collection'

  assert_equal 'application/xml', @response.content_type



  with_options :tag => 'books' do |test|

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'title', :content => 'Books' } }

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'book',  :content => ''      } }

  end



  with_options :tag => 'book' do |test|

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'id',    :content => books(:http_book).id.to_s } }

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'title', :content => books(:http_book).title   } }

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'link',  :attributes => { :href => @controller.url_for(:action => 'by_id', :id => books(:http_book)) } } }

  end

end

This tests that when a GET :collection is performed, a 200 OK status code should be returned. Also the collection template should be used to render the results in the specified XML format.

rake test:functionals should fail because we haven't created the XML template yet.

Before creating the template we need a way to retrieve a list of all the books from the database. Add one line to the beginning of the :collection resource in app/controllers/book_controller.rb so it becomes:

resource :collection do |r|

  conditions << @books = Book.find(:all)



  r.post do

  end

end

Note that the assignment of @book to conditions is a convention in RESTful Rails. It allows conditional request handling which may be described in more detail at a later time.

Next create the template app/views/book/collection.rxml and make it look like:

xml.instruct!



xml.books do

  xml.title 'Books'



  @books.each do |book|

    xml.book do

      xml.id    book.id

      xml.title book.title

      xml.link  :href => url_for(:only_path => false, :action => 'by_id', :id => book)

    end

  end

end

rake test:functionals should now pass.

Add to the List of Books

So what exactly should we do when handling POST in :collection? Well, its job will be to add a new book to the database as well as redirect the client to the new location of the book. With these expectations we add the following to test/functional/book_controller_test.rb:

def test_post_collection

  new_book = { :title => 'test' }



  post :collection, :book => new_book

  assert_response HTTP::Status::CREATED



  id = Book.maximum('id')

  assert_location :action => 'by_id', :id => id



  book = Book.find(id)

  assert_kind_of Book, book

  assert_equal new_book[:title], book.title

  assert_equal new_book[:description], book.description

end

This tests that when a POST is performed on :collection, a 201 Created status code should be returned. Also the Location header should be set properly and a database check to should be made to ensure the new book was actually created.

rake test:functionals should fail because we haven't added the :collection POST handler yet.

To handle POST requests, we update the r.post handler in app/controllers/book_controller.rb to:

r.post do

  @book = Book.new params[:book]

  if @book.save

    render_post_success :action => 'by_id', :id => @book

  end

end

In this example we use the RESTful Rails render_post_success method which returns a 201 Created status, and sets the Location header to the URI of the new book.

rake test:functionals should now pass.

We're done with the :collection resource now. It handles GET, HEAD, OPTIONS, and POST requests in about nine lines of code. Next we'll move on to the :by_id resource which represents a book using its ID in the database.

The by_id Resource

The :by_id resource is a single book identified by its database ID. We want to be able to view a book with the GET method, change it with PUT, and delete it with the DELETE method. Let's add a test to test/functional/book_controller_test.rb that tests our expectations:

def test_options_by_id

  options :by_id, :id => books(:http_book).id

  assert_response HTTP::Status::NO_CONTENT

  assert_no_entity

  assert_allowed_methods :get, :put, :delete

end

This tests that when OPTIONS is performed on :by_id, a 204 No Content status code is returned. Also the Allow header should identify GET, PUT, and DELETE as allowed methods.

rake test:functionals should fail because there is no for :by_id resource yet.

The process we use is very similar to the :collection resource—we'll stub out the :by_id resource to make the functional tests pass for now. Add the following to app/controllers/book_controller.rb:

resource :by_id do |r|

  r.put do

  end



  r.delete do

  end

end

Again, because the RESTful Rails plugin handles requests just like normal Rails, we don't have to do anything to handle GET.

rake test:functionals should now pass.

View a Book

We need to test what will happen when a GET on :by_id is performed. In our case we want the by_id template to be loaded and display a single book from the database in XML format. Let's test this by adding the following to test/functional/book_controller_test.rb:

def test_get_by_id

  get :by_id, :id => books(:http_book).id

  assert_response HTTP::Status::OK

  assert_template 'by_id'

  assert_equal 'application/xml', @response.content_type



  with_options :tag => 'book' do |test|

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'id',          :content => books(:http_book).id.to_s     } }

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'title',       :content => books(:http_book).title       } }

    test.assert_tag :children => { :count  => 1, :only => { :tag => 'description', :content => books(:http_book).description } }

  end

end

This tests that when a GET on :by_id is performed, a 200 OK status code should be returned, and that the by_id template should be used to render the results in the specified XML format.

rake test:functionals should fail because we haven't created the XML template yet.

Before creating the template we first need a way to retrieve the book from the database using the id supplied in the URI. Add one line to the beginning of the :by_id resource in app/controllers/book_controller.rb so it becomes:

resource :by_id do |r|

  conditions << @book = Book.find(params[:id])



  r.put do

  end



  r.delete do

  end

end

Next create the template app/views/book/by_id.rxml and make it look like:

xml.instruct!

@book.to_xml

rake test:functionals should now pass.

Change a Book

Now we want to be able to use PUT to change a book by its ID. We test this by adding the following to test/functional/book_controller_test.rb:

def test_put_by_id

  changed_book = { :title => 'a different title', :description => 'a different description' }



  id = books(:http_book).id



  put :by_id, :id => id, :book => changed_book

  assert_response HTTP::Status::NO_CONTENT

  assert_no_entity



  book = Book.find id

  assert_kind_of Book, book

  assert_equal changed_book[:title], book.title

  assert_equal changed_book[:description], book.description

end

This tests that when PUT is performed on :by_id, a 204 No Content status code is returned. The test checks the database to ensure the record was changed successfully.

rake test:functionals should fail because we haven't added the :by_id PUT handler yet.

To handle PUT requests we update the r.put handler in app/controllers/book_controller.rb to:

r.put do

  @book.attributes = params[:book]

  if @book.save

    render_put_success

  end

end

We use the RESTful Rails render_put_success method which returns a 204 No Content status.

rake test:functionals should now pass.

Delete a Book

Finally, we want to use DELETE to remove a book by its ID.

The test we add to test/functional/book_controller_test.rb looks like this:

def test_delete_by_id

  id = books(:http_book).id



  delete :by_id, :id => id

  assert_response HTTP::Status::NO_CONTENT

  assert_no_entity



  assert_raise(ActiveRecord::RecordNotFound) { Book.find id }

end

This tests that when DELETE is performed on :by_id, a 204 No Content status code is returned. The test checks the database to ensure the record was removed successfully.

rake test:functionals should fail because we haven't added the :by_id DELETE handler yet.

To handle DELETE requests we update the r.delete handler in app/controllers/book_controller.rb to:

r.delete do

  if @book.destroy

    render_delete_success :id => nil

  end

end

This simply calls @book.destroy and does a render_delete_success when successful.

rake test:functionals should now pass.

Trust, but Verify

It's always nice to have test cases that pass, but what about the real world? When I first write my test cases I always like to double-check them to make sure everything is working perfectly. Let's issue a few requests directly to our application using trusty old curl.

Test Viewing a List of Books

Before we start we need to bring up a test server with the following command:

ruby script/server

Then open a second command-line prompt and run the following:

curl http://localhost:3000/books/

The expected response is:

<?xml version="1.0" encoding="UTF-8"?>

<books>

  <title>Books</title>

</books>

As you can see, there are no books listed yet.

Test Adding to a List of Books

Next, create a new book:

curl \

  -i \

  -X POST \

  -H 'Content-Type: application/xml' \

  -d '<book><title>a title</title><description>a description</description></book>' \

  http://localhost:3000/books/

The response to this request will include several headers, but the ones we care about are:

HTTP/1.1 201 Created

Location: http://localhost:3000/books/1

Note the URI in the Location header, we'll be using that in a moment.

To make sure the new book is listed in the collection:

curl http://localhost:3000/books/

The expected response is:

<?xml version="1.0" encoding="UTF-8"?>

<books>

  <title>Books</title>

  <book>

    <id>1</id>

    <title>a title</title>

    <link href="http://localhost:3000/books/1"/>

  </book>

</books>

Make sure the link's href is the same as the Location header we got from the previous step.

Test Viewing a Book

Follow the Location header from the previous step:

curl http://localhost:3000/books/1

The expected response is:

<?xml version="1.0" encoding="UTF-8"?>

<book>

  <title>a title</title>

  <id type="integer">1</id>

  <description>a description</description>

</book>

Test Changing a Book

Update the book using PUT:

curl \

  -i \

  -X PUT \

  -H 'Content-Type: application/xml' \

  -d '<book><title>a new title</title><description>a new description</description></book>' \

  http://localhost:3000/books/1

This too will return several headers, but at this moment we only care about the first line:

HTTP/1.1 204 No Content

Retrieve the book again to see the changes:

curl http://localhost:3000/books/1

The expected response is:

<?xml version="1.0" encoding="UTF-8"?>

<book>

  <title>a new title</title>

  <id type="integer">1</id>

  <description>a new description</description>

</book>

Test Deleting a Book

We'll delete the book like this:

curl -i -X DELETE http://localhost:3000/books/1

Again, there will be several headers, but the one that tells us the DELETE was successful is the first line:

HTTP/1.1 204 No Content

To verify the book is deleted, perform a GET on the collection:

curl http://localhost:3000/books/

The expected response is:

<?xml version="1.0" encoding="UTF-8"?>

<books>

  <title>Books</title>

</books>

The collection contains no books now, just as we would expect.

Only the Beginning

So now we have a simple RESTful Rails application with a full test suite and HTTP method dispatching. We used the HTTP OPTIONS method to identify the methods each resource supports, and we based our test cases around a relatively simple RESTful protocol.

As far as building RESTful Rails applications in Ruby on Rails we're only at the beginning of what is possible. There's an increased interest by the Rails core development team, and David Heinemeier Hansson in particular, in HTTP and the REST architectural style. The RESTful Rails project is off to a good start with per-method dispatching; conditional GET, HTTP Authentication, and more are planned in the near future.

Given the momentum of Ruby on Rails it's a safe bet that in the coming months you'll see more RESTful applications and a better understanding of the HTTP protocol within the community.