Putting REST on Rails
April 19, 2006
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.