Tutorial

Build a RESTful JSON API With Rails 5 - Part One

Updated on September 15, 2020
author

Austin Kabiru

Build a RESTful JSON API With Rails 5 - Part One

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

Introduction

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:

  • Start the application with a limited set of middleware
  • Make the ApplicationController inherit from ActionController::API instead of ActionController::Base
  • Skip generation of view files

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.

Prerequisites

Before we begin, make sure you have ruby version >=2.2.2 and rails version 5.

  1. ruby -v # ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin16]
  2. 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.

  1. # when using rbenv
  2. rbenv install 2.3.1
  1. # set 2.3.1 as the global version
  2. rbenv global 2.3.1
  1. # when using rvm
  2. rvm install 2.3.1
  3. # set 2.3.1 as the global version
  4. rvm use 2.3.1

If your rails version is not up to date, update to the latest version by running:

  1. gem update rails

All good? Let’s get started!

API Endpoints

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:

  • Project setup
  • Todos API
  • TodoItems API

Project Setup

Generate a new project todos-api by running:

  1. 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.

Dependencies

Let’s take a moment to review the gems that we’ll be using.

  • rspec-rails - Testing framework.
  • factory_bot_rails - A fixtures replacement with a more straightforward syntax. You’ll see.
  • shoulda_matchers - Provides RSpec with additional matchers.
  • database_cleaner - You guessed it! It literally cleans our test database to ensure a clean state in each test suite.
  • faker - A library for generating fake data. We’ll use this to generate test data.

All good? Great! Let’s set them up. In your Gemfile:

Add rspec-rails to both the :development and :test groups.

Gemfile
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.

Gemfile
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:

  1. bundle install

Initialize the spec directory (where our tests will reside).

  1. 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.

  1. mkdir spec/factories

Configuration

In spec/rails_helper.rb

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.

Models

Let’s start by generating the Todo model

  1. 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.

db/migrate/[timestamp]_create_todos.rb
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

  1. 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:

  • Add a foreign key column todo_id to the items table
  • Setup a belongs_to association in the Item model
db/migrate/[timestamp]_create_items.rb
class 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.

  1. rails db:migrate

We’re Test Driven, let’s write the model specs first.

spec/models/todo_spec.rb
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.

spec/models/item_spec.rb
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:

  1. bundle exec rspec

And to no surprise, we have only one test passing and four failures. Let’s go ahead and fix the failures.

app/models/todo.rb
class Todo < ApplicationRecord
  # model association
  has_many :items, dependent: :destroy

  # validations
  validates_presence_of :title, :created_by
end
app/models/item.rb
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.

Controllers

Now that our models are all set up, let’s generate the controllers.

  1. rails g controller Todos
  2. 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.

  1. mkdir spec/requests
  2. 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:

  1. touch spec/factories/{todos.rb,items.rb}

Define the factories.

spec/factories/todos.rb
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.

spec/factories/items.rb
FactoryBot.define do
  factory :item do
    name { Faker::StarWars.character }
    done false
    todo_id nil
  end
end

Todo API

spec/requests/todos_spec.rb
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:

  1. mkdir spec/support && touch spec/support/request_spec_helper.rb
spec/support/request_spec_helper
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.

spec/rails_helper.rb
# [...]
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.

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:

  1. 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.

app/controllers/todos_controller.rb
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.
app/controllers/concerns/response.rb
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.
app/controllers/concerns/exception_handler.rb
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.

app/controllers/application_controller.rb
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.

  1. rails s

Now let’s go ahead and make requests to the API. I’ll be using httpie as my HTTP client.

  1. # GET /todos
  2. http :3000/todos
  1. # POST /todos
  2. http POST :3000/todos title=Mozart created_by=1
  1. # PUT /todos/:id
  2. http PUT :3000/todos/1 title=Beethoven
  1. # DELETE /todos/:id
  2. http DELETE :3000/todos/1

You should see similar output.

TodoItems API

spec/requests/items_spec.rb
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.

app/controllers/items_controller.rb
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:

  1. # GET /todos/:todo_id/items
  2. http :3000/todos/2/items
  1. # POST /todos/:todo_id/items
  2. http POST :3000/todos/2/items name='Listen to 5th Symphony' done=false
  1. # PUT /todos/:todo_id/items/:id
  2. http PUT :3000/todos/2/items/1 done=true
  1. # DELETE /todos/:todo_id/items/1
  2. http DELETE :3000/todos/2/items/1

Conclusion

That’s it for part one! At this point you should have learned how to:

  • Generate an API application with Rails 5
  • Setup RSpec testing framework with Factory Bot, Database Cleaner, Shoulda Matchers, and Faker.
  • Build models and controllers with TDD (Test Driven Development).
  • Make HTTP requests to an API with httpie.

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.

Learn more about our products

About the authors
Default avatar
Austin Kabiru

author

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
10 Comments


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 of Faker::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:

#spec/factories/todos.rb
4: created_by { Faker::Number.number(digits: 10) }
#spec/factories/items.rb
4: done { false }
5: todo_id { nil }

In the ‘create’ action of the item controller.

If you make it this way:

  def create
    @todo.items.create!(item_params)
    json_response(@todo, :created)
  end

After you create an item, the API will return the Todo of the Item. But if you make it this way:

  def create
    @item = @todo.items.create!(item_params)
    json_response(@item, :created)
  end

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:

NameError:
uninitialized constant [variable name]

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:

NameError:
       undefined local variable or method `json'

It seems to be a typo, at:

RSpec.configuration do |config|
  # [...]
  config.include RequestSpecHelper, type: :request
  # [...]
end

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

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Featured on Community

Get our biweekly newsletter

Sign up for Infrastructure as a Newsletter.

Hollie's Hub for Good

Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.

Become a contributor

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

Welcome to the developer cloud

DigitalOcean makes it simple to launch in the cloud and scale up as you grow — whether you're running one virtual machine or ten thousand.

Learn more