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.