This tutorial is out of date and no longer maintained.
Note: Part one of a three-part series.
Build a RESTful JSON API With Rails 5 - Part One Build a RESTful JSON API With Rails 5 - Part Two Build a RESTful JSON API With Rails 5 - Part Three
Rails is popularly known for building web applications. Chances are if you’re reading this you’ve built a traditional server-rendered web application with Rails before. If not, I’d highly recommend going through the Getting Started with Rails page to familiarize yourself with the Rails framework before proceeding with this tutorial.
As of version 5, Rails core now supports API-only applications! In previous versions, we relied on an external gem: rails-api which has since been merged to core rails.
API-only applications are slimmed down compared to traditional Rails web applications. According to Rails 5 release notes, generating an API only application will:
ApplicationController
inherit from ActionController::API
instead of ActionController::Base
This works to generate an API-centric framework excluding functionality that would otherwise be unused and unnecessary.
In this three-part tutorial, we’ll build a todo list API where users can manage their to-do lists and todo items.
Before we begin, make sure you have ruby version >=2.2.2 and rails version 5.
- ruby -v # ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin16]
- rails -v # Rails 5.0.1
If your ruby version is not up to date, you can update it with a ruby version manager like rvm or rbenv.
- # when using rbenv
- rbenv install 2.3.1
- # set 2.3.1 as the global version
- rbenv global 2.3.1
- # when using rvm
- rvm install 2.3.1
- # set 2.3.1 as the global version
- rvm use 2.3.1
If your rails version is not up to date, update to the latest version by running:
- gem update rails
All good? Let’s get started!
Our API will expose the following RESTful endpoints.
Endpoint | Functionality |
---|---|
POST /signup | Signup |
POST /auth/login | Login |
GET /auth/logout | Logout |
GET /todos | List all todos |
POST /todos | Create a new todo |
GET /todos/:id | Get a todo |
PUT /todos/:id | Update a todo |
DELETE /todos/:id | Delete a todo and its items |
GET /todos/:id/items | Get a todo item |
PUT /todos/:id/items | Update a todo item |
DELETE /todos/:id/items | Delete a todo item |
Part One will Cover:
Generate a new project todos-api
by running:
- rails new todos-api --api -T
Note that we’re using the --api
argument to tell Rails that we want an API application and -T
to exclude Minitest the default
testing framework. Don’t freak out, we’re going to write tests. We’ll be using RSpec instead to test our API. I find RSpec to be more expressive and easier to start with as compared to Minitest.
Let’s take a moment to review the gems that we’ll be using.
All good? Great! Let’s set them up. In your Gemfile
:
Add rspec-rails
to both the :development
and :test
groups.
group :development, :test do
gem 'rspec-rails', '~> 3.5'
end
This is a handy shorthand to include a gem in multiple environments.
Add factory_bot_rails
, shoulda_matchers
, faker
and database_cleaner
to the :test
group.
group :test do
gem 'factory_bot_rails', '~> 4.0'
gem 'shoulda-matchers', '~> 3.1'
gem 'faker'
gem 'database_cleaner'
end
Install the gems by running:
- bundle install
Initialize the spec
directory (where our tests will reside).
- rails generate rspec:install
This adds the following files which are used for configuration:
.rspec
spec/spec_helper.rb
spec/rails_helper.rb
Create a factories
directory (factory bot uses this as the default directory). This is where we’ll define the model factories.
- mkdir spec/factories
In spec/rails_helper.rb
# require database cleaner at the top level
require 'database_cleaner'
# [...]
# configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
# [...]
RSpec.configure do |config|
# [...]
# add `FactoryBot` methods
config.include FactoryBot::Syntax::Methods
# start by truncating all the tables but then use the faster transaction strategy the rest of the time.
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
DatabaseCleaner.strategy = :transaction
end
# start the transaction strategy as examples are run
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
# [...]
end
Phew! That was rather long. Good thing is, it’s a smooth ride from here on out.
Let’s start by generating the Todo
model
- rails g model Todo title:string created_by:string
Notice that we’ve included the model attributes in the model generation command. This way we don’t have to edit the migration file.
The generator invokes active record
and rspec
to generate the migration, model, and spec respectively.
class CreateTodos < ActiveRecord::Migration[5.0]
def change
create_table :todos do |t|
t.string :title
t.string :created_by
t.timestamps
end
end
end
And now the Item
model
- rails g model Item name:string done:boolean todo:references
By adding todo:references
we’re telling the generator to set up an association with the Todo
model.
This will do the following:
todo_id
to the items
tablebelongs_to
association in the Item modelclass CreateItems < ActiveRecord::Migration[5.0]
def change
create_table :items do |t|
t.string :name
t.boolean :done
t.references :todo, foreign_key: true
t.timestamps
end
end
end
Looks good? Let’s run the migrations.
- rails db:migrate
We’re Test Driven, let’s write the model specs first.
require 'rails_helper'
# Test suite for the Todo model
RSpec.describe Todo, type: :model do
# Association test
# ensure Todo model has a 1:m relationship with the Item model
it { should have_many(:items).dependent(:destroy) }
# Validation tests
# ensure columns title and created_by are present before saving
it { should validate_presence_of(:title) }
it { should validate_presence_of(:created_by) }
end
RSpec has a very expressive DSL (Domain Specific Language). You can almost read the tests like a paragraph.
Remember our shoulda matchers gem? It provides RSpec with the nifty association and validation matchers above.
require 'rails_helper'
# Test suite for the Item model
RSpec.describe Item, type: :model do
# Association test
# ensure an item record belongs to a single todo record
it { should belong_to(:todo) }
# Validation test
# ensure column name is present before saving
it { should validate_presence_of(:name) }
end
Let’s execute the specs by running:
- bundle exec rspec
And to no surprise, we have only one test passing and four failures. Let’s go ahead and fix the failures.
class Todo < ApplicationRecord
# model association
has_many :items, dependent: :destroy
# validations
validates_presence_of :title, :created_by
end
class Item < ApplicationRecord
# model association
belongs_to :todo
# validation
validates_presence_of :name
end
At this point run the tests again and…
Voila! All green.
Now that our models are all set up, let’s generate the controllers.
- rails g controller Todos
- rails g controller Items
You guessed it! Tests first… with a slight twist. Generating controllers by default generates controller specs.
However, we won’t be writing any controller specs. We’re going to write request specs instead.
Request specs are designed to drive behavior through the full stack, including routing. This means they can hit the applications’ HTTP endpoints as opposed to controller specs which call methods directly. Since we’re building an API application, this is exactly the kind of behavior we want from our tests.
According to RSpec, the official recommendation of the Rails team and the RSpec core team is to write request specs instead.
Add a requests
folder to the spec
directory with the corresponding spec files.
- mkdir spec/requests
- touch spec/requests/{todos_spec.rb,items_spec.rb}
Before we define the request specs, Let’s add the model factories which will provide the test data.
Add the factory files:
- touch spec/factories/{todos.rb,items.rb}
Define the factories.
FactoryBot.define do
factory :todo do
title { Faker::Lorem.word }
created_by { Faker::Number.number(10) }
end
end
By wrapping Faker methods in a block, we ensure that Faker generates dynamic data every time the factory is invoked.
This way, we always have unique data.
FactoryBot.define do
factory :item do
name { Faker::StarWars.character }
done false
todo_id nil
end
end
require 'rails_helper'
RSpec.describe 'Todos API', type: :request do
# initialize test data
let!(:todos) { create_list(:todo, 10) }
let(:todo_id) { todos.first.id }
# Test suite for GET /todos
describe 'GET /todos' do
# make HTTP get request before each example
before { get '/todos' }
it 'returns todos' do
# Note `json` is a custom helper to parse JSON responses
expect(json).not_to be_empty
expect(json.size).to eq(10)
end
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
end
# Test suite for GET /todos/:id
describe 'GET /todos/:id' do
before { get "/todos/#{todo_id}" }
context 'when the record exists' do
it 'returns the todo' do
expect(json).not_to be_empty
expect(json['id']).to eq(todo_id)
end
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
end
context 'when the record does not exist' do
let(:todo_id) { 100 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
# Test suite for POST /todos
describe 'POST /todos' do
# valid payload
let(:valid_attributes) { { title: 'Learn Elm', created_by: '1' } }
context 'when the request is valid' do
before { post '/todos', params: valid_attributes }
it 'creates a todo' do
expect(json['title']).to eq('Learn Elm')
end
it 'returns status code 201' do
expect(response).to have_http_status(201)
end
end
context 'when the request is invalid' do
before { post '/todos', params: { title: 'Foobar' } }
it 'returns status code 422' do
expect(response).to have_http_status(422)
end
it 'returns a validation failure message' do
expect(response.body)
.to match(/Validation failed: Created by can't be blank/)
end
end
end
# Test suite for PUT /todos/:id
describe 'PUT /todos/:id' do
let(:valid_attributes) { { title: 'Shopping' } }
context 'when the record exists' do
before { put "/todos/#{todo_id}", params: valid_attributes }
it 'updates the record' do
expect(response.body).to be_empty
end
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
before { delete "/todos/#{todo_id}" }
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
We start by populating the database with a list of 10 todo records (thanks to factory bot). We also have a custom helper method json
which parses the JSON response to a Ruby Hash which is easier to work with in our tests.
Let’s define it in spec/support/request_spec_helper
.
Add the directory and file:
- mkdir spec/support && touch spec/support/request_spec_helper.rb
module RequestSpecHelper
# Parse JSON response to ruby hash
def json
JSON.parse(response.body)
end
end
The support directory is not autoloaded by default. To enable this, open the rails helper and comment out the support directory auto-loading and then include it as shared module for all request specs in the RSpec configuration block.
# [...]
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
# [...]
RSpec.configuration do |config|
# [...]
config.include RequestSpecHelper, type: :request
# [...]
end
Run the tests.
We get failing routing errors. This is because we haven’t defined the routes yet. Go ahead and define them in config/routes.rb
.
Rails.application.routes.draw do
resources :todos do
resources :items
end
end
In our route definition, we’re creating todo resource with a nested items resource. This enforces the 1:m (one to many) associations at the routing level. To view the routes, you can run:
- rails routes
When we run the tests we see that the routing error is gone. As expected we have controller failures. Let’s go ahead and define the controller methods.
class TodosController < ApplicationController
before_action :set_todo, only: [:show, :update, :destroy]
# GET /todos
def index
@todos = Todo.all
json_response(@todos)
end
# POST /todos
def create
@todo = Todo.create!(todo_params)
json_response(@todo, :created)
end
# GET /todos/:id
def show
json_response(@todo)
end
# PUT /todos/:id
def update
@todo.update(todo_params)
head :no_content
end
# DELETE /todos/:id
def destroy
@todo.destroy
head :no_content
end
private
def todo_params
# whitelist params
params.permit(:title, :created_by)
end
def set_todo
@todo = Todo.find(params[:id])
end
end
More helpers. Yay! This time we have:
json_response
which does… yes, responds with JSON and an HTTP status code (200 by default).
We can define this method in concerns folder.module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
set_todo
- callback method to find a todo by id. In the case where the record does not exist, ActiveRecord will throw an exception ActiveRecord::RecordNotFound
. We’ll rescue from this exception and return a 404
message.module ExceptionHandler
# provides the more graceful `included` method
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
rescue_from ActiveRecord::RecordInvalid do |e|
json_response({ message: e.message }, :unprocessable_entity)
end
end
end
In our create
method in the TodosController
, note that we’re using create!
instead of create
. This way, the model will raise an exception ActiveRecord::RecordInvalid
. This way, we can avoid deep nested if statements in the controller. Thus, we rescue from this exception in the ExceptionHandler
module.
However, our controller classes don’t know about these helpers yet. Let’s fix that by including these modules in the application controller.
class ApplicationController < ActionController::API
include Response
include ExceptionHandler
end
Run the tests and everything’s all green!
Let’s fire up the server for some good old manual testing.
- rails s
Now let’s go ahead and make requests to the API. I’ll be using httpie as my HTTP client.
- # GET /todos
- http :3000/todos
- # POST /todos
- http POST :3000/todos title=Mozart created_by=1
- # PUT /todos/:id
- http PUT :3000/todos/1 title=Beethoven
- # DELETE /todos/:id
- http DELETE :3000/todos/1
You should see similar output.
require 'rails_helper'
RSpec.describe 'Items API' do
# Initialize the test data
let!(:todo) { create(:todo) }
let!(:items) { create_list(:item, 20, todo_id: todo.id) }
let(:todo_id) { todo.id }
let(:id) { items.first.id }
# Test suite for GET /todos/:todo_id/items
describe 'GET /todos/:todo_id/items' do
before { get "/todos/#{todo_id}/items" }
context 'when todo exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns all todo items' do
expect(json.size).to eq(20)
end
end
context 'when todo does not exist' do
let(:todo_id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
# Test suite for GET /todos/:todo_id/items/:id
describe 'GET /todos/:todo_id/items/:id' do
before { get "/todos/#{todo_id}/items/#{id}" }
context 'when todo item exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns the item' do
expect(json['id']).to eq(id)
end
end
context 'when todo item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for PUT /todos/:todo_id/items
describe 'POST /todos/:todo_id/items' do
let(:valid_attributes) { { name: 'Visit Narnia', done: false } }
context 'when request attributes are valid' do
before { post "/todos/#{todo_id}/items", params: valid_attributes }
it 'returns status code 201' do
expect(response).to have_http_status(201)
end
end
context 'when an invalid request' do
before { post "/todos/#{todo_id}/items", params: {} }
it 'returns status code 422' do
expect(response).to have_http_status(422)
end
it 'returns a failure message' do
expect(response.body).to match(/Validation failed: Name can't be blank/)
end
end
end
# Test suite for PUT /todos/:todo_id/items/:id
describe 'PUT /todos/:todo_id/items/:id' do
let(:valid_attributes) { { name: 'Mozart' } }
before { put "/todos/#{todo_id}/items/#{id}", params: valid_attributes }
context 'when item exists' do
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
it 'updates the item' do
updated_item = Item.find(id)
expect(updated_item.name).to match(/Mozart/)
end
end
context 'when the item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
before { delete "/todos/#{todo_id}/items/#{id}" }
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
As expected, running the tests at this point should output failing todo item tests. Let’s define the todo items controller.
class ItemsController < ApplicationController
before_action :set_todo
before_action :set_todo_item, only: [:show, :update, :destroy]
# GET /todos/:todo_id/items
def index
json_response(@todo.items)
end
# GET /todos/:todo_id/items/:id
def show
json_response(@item)
end
# POST /todos/:todo_id/items
def create
@todo.items.create!(item_params)
json_response(@todo, :created)
end
# PUT /todos/:todo_id/items/:id
def update
@item.update(item_params)
head :no_content
end
# DELETE /todos/:todo_id/items/:id
def destroy
@item.destroy
head :no_content
end
private
def item_params
params.permit(:name, :done)
end
def set_todo
@todo = Todo.find(params[:todo_id])
end
def set_todo_item
@item = @todo.items.find_by!(id: params[:id]) if @todo
end
end
Run the tests.
Run some manual tests for the todo items API:
- # GET /todos/:todo_id/items
- http :3000/todos/2/items
- # POST /todos/:todo_id/items
- http POST :3000/todos/2/items name='Listen to 5th Symphony' done=false
- # PUT /todos/:todo_id/items/:id
- http PUT :3000/todos/2/items/1 done=true
- # DELETE /todos/:todo_id/items/1
- http DELETE :3000/todos/2/items/1
That’s it for part one! At this point you should have learned how to:
RSpec
testing framework with Factory Bot
, Database Cleaner
, Shoulda Matchers
, and Faker
.In the next part, we’ll cover authentication
with JWT, pagination
, and API versioning
. Hope to see you there. Cheers!
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
While using Rails 6, adding gem ‘rubocop-faker’ gem ‘rubocop-rspec’, require: false and running rubocop --autocorrect help fixed some syntax that was not compatible with rails 6. Great tutorial, keep it coming.
Excellent article, the entire series actually!
There’s one small error in the command for Faker, it should be
Faker::Movies::StarWars.character
instead ofFaker::StarWars.character
thank u very much! This was really helpful
Excellent walkthrough, thanks for writing it up. A couple of easy tweaks can be edited in the factory file examples so the current version of Faker doesn’t pile on the depreciation messages upon running RSpec:
In the ‘create’ action of the item controller.
If you make it this way:
After you create an item, the API will return the Todo of the Item. But if you make it this way:
You will get the Item after creating the item with the id in the database.
I had to add these lines to the spec/spec_helper.rb file:
require ‘database_cleaner’ require ‘shoulda/matchers’ require ‘factory_bot_rails’ require ‘action_controller’
To avoid getting a lot of errors like these:
when running the tests.
Which by the way I ran them by using the command:
$ rspec
Is there a risk of running test with that command instead of:
$ bundle exec rspec
?
I was getting an error:
It seems to be a typo, at:
The “RSpec.configuration” should be “RSpec.configure”.
I am facing this error
NameError: uninitialized constant Shoulda
I am running Rails 6.0.3.4
can someone help me with this issue?
This was a really well-written article and helpful. Thanks and keep it coming!
Nice tutorial, I can’t wait for the second part