はじめに
今回はRESTfulなAPI開発の基礎的なところを勉強していきます.
なお,本記事はBuild a RESTful JSON API With Rails 5 - Part Oneの内容を自分のために翻訳+自分にとって分かりやすくまとめたものになります.
最終的なコードはGithubにあげてあります.
開発環境
- Mac
- Ruby 2.5.0
- Rails 5.1.4
エンドポイント
今回作成するAPIのエンドポイントは以下のようになっています.
エンドポイント | 機能 |
---|---|
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 |
セットアップ
プロジェクト作成
$ rails new todos-api --api -T
- todos-api:プロジェクト名
- --api:API開発モードの指定オプション
- -T:デフォルトのテスティングフレームワーク「Minitest」をオフにするオプション
今回はテスティングフレームワークとしてRSpecを使用.
- ポイント -
「--api」オプションによりAPI開発を行うことをRailsに知らせる.
Gem インストール
以下のGemを利用します.
- spec-rails:テスティングフレームワーク
- faker:テストデータの作成
- factory_girl_rails:テストデータの作成補助
- shoulda_matchers:テストのコーディング補助
- database_cleaner:データベースの掃除屋さん
Gemfileに追記
# devとtest環境でのみ使用
group :development, :test do
gem 'rspec-rails', '~> 3.5'
end
# test環境でのみ使用
group :test do
gem 'factory_girl_rails', '~> 4.0'
gem 'shoulda-matchers', '~> 3.1'
gem 'faker'
gem 'database_cleaner'
end
gem インストール
$ bundle install
RSpecの利用
RSpecの初期化
$ rails generate rspec:install
Factory Girl用のディレクトリ作成
$ mkdir spec/factories
最後に,RSpecの設定ファイルを編集します.
# 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 `FactoryGirl` methods
config.include FactoryGirl::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
とりあえず,ここまででRailsでAPIを開発していくためのセットアップは完了.
次章からどんどん中身を作っていきます.
モデルの作成
Todoモデル
まずはTodoモデルを作っていきます.
$ rails g model Todo title:string created_by:string
中身はこんな感じ(勝手にできてます)
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
上記コードより
titleおよびcreated_byというカラムを持ったtodosテーブルが作成されます.
Itemモデル
次はItemモデルの作成
$ rails g model Item name:string done:boolean todo:references
中身はこんな感じ(勝手にできてます)
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
上記コードより
nameおよびdoneというカラムを持ったitemsテーブルができます(4, 5行目).
なお,itemsテーブルでは,todoカラム(todo_id)が外部キーに指定されています(6行目).
一通りモデルを書き終わったので,ここでmigrate
$ rails db:migrate
テストコードの作成
本記事では,「赤→緑→リファクタリング」の手順で開発を進めます.
よって,RSpecを用いて,まずはテストコードを作っていきます.
(リファクタリングはしてないですが・・・)
Todoモデルに対するテスト
Todoモデルに対しては以下のテストを行います.
- TodoモデルがItemモデルと1対mの関係である(下記コード7行目)
- 保存前にtitleカラムが存在する(下記コード10行目)
- 保存前にcreated_byカラムが存在する(下記コード11行目)
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
Itemモデルに対するテスト
Itemモデルに対しては以下のテストを行います.
- itemレコードはただひとつのtodoレコードに属する(下記コード7行目)
- 保存前にnameカラムが存在する(下記コード10行目)
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
テストの実行
テストを実行します.
$ bundle exec rspec
するとひとつのテストをパスしますが,以下の4つのエラーが出ると思います.
- Item should validate that :name cannot be empty/falsy
- Todo should have many items dependent => destroy
- Todo should validate that :title cannot be empty/falsy
- Todo should validate that :created_by cannot be empty/falsy
エラーの内3つは中身がないよというエラーで,後の1つは依存関係のエラーです.
そりゃ,モデルのマイグレーションファイルを作っただけで,中身をちゃんと作ってないんですからエラーを吐かれて当然ですね.
次はこれらの赤色達を緑色にしていきます.
モデルの修正
モデルの中身をしっかり作っていきましょう.
class Todo < ApplicationRecord
# model association
has_many :items, dependent: :destroy
# validations
validates_presence_of :title, :created_by
end
3行目の「has_many」は,Todoモデルが複数のitemsを持つことを意味します.
同じく3行目の「dependent」は,親であるTodoが削除されたときに子であるitemsをどうするかを決定する.今回はTodoが消えれば,それに付随するitemsも削除することにする.
6行目の「validates_presence_of」は,指定したカラムにレコードが存在するかを確認するバリデーションである.
class Item < ApplicationRecord
# model association
belongs_to :todo
# validation
validates_presence_of :name
end
3行目の「belongs_to」は,先程のtodo.rbの「has_many」に対応しており,Itemがtodoに追従することを示す.
以上でモデルの中身ができたのでテストを実行します.
すると,5個全てのテストが緑色に変わったと思います.
さて,これでモデルが完成したので,次からはコントローラの作成に移ります.
コントローラの作成
コントローラにより,どのURIを叩けばどういったデータが得られるか決まってきます.
とりあえずRailsさんの力を借りてコントローラの土台を作ってしまいます.
$ rails g controller Todos && rails g controller Items
Todosコントローラ用のテストコード作成
モデルと同様にコントローラ用のテストを作ります.
まずは,APIを叩いたときの挙動に関するテストです.
では,初めにTodoコントローラに対してテストを作っていきます.
APIリクエスト関連のテストコードを格納するディレクトリおよび対応ファイルを作成.
$ mkdir spec/requests && touch spec/requests/todos_spec.rb
Todosコントローラに対するテスト
Todosコントローラに対しては以下のテストを行います.
- 「GET /todos」を叩いたときレコードが空でなく,10個存在することを確認する(下記コード13行目)
- 「GET /todos」を叩いたときリクエストが正常であれば,ステータスコード200を返す(下記コード19行目)
- 「GET /todos/:id」を叩いたとき該当IDのレコードが存在すれば,そのレコードが空でなくidがtodo_idと同じか確認する(下記コード29行目)
- 「GET /todos/:id」を叩いたときリクエストが正常であれば,ステータスコード200を返す(下記コード34行目)
- 「GET /todos/:id」を叩いたときに該当レコードがなければ,エラーメッセージを返す(下記コード46行目)
- 「GET /todos/:id」を叩いたときに該当レコードがなければ,ステータスコード404を返す(下記コード42行目)
- 「POST /todos」を叩いたときリクエストが正常であれば,「Learn Elm」というtodoを作成できているか確認する(下記コード60行目)
- 「POST /todos」を叩いたときリクエストが正常であれば,ステータスコード201を返す(下記コード64行目)
- 「POST /todos」を叩いたときリクエストが異常であれば,エラーメッセージを返す(下記コード76行目)
- 「POST /todos」を叩いたときリクエストが異常であれば,ステータスコード422を返す(下記コード72行目)
- 「PUT /todos/:id」を叩いたとき該当IDのレコードが存在すれば,それが空であることを確認する(下記コード90行目)
- 「PUT /todos/:id」を叩いたときリクエストが正常であれば,ステータスコード204を返す(下記コード94行目)
- 「DELETE /todos/:id」を叩いたときリクエストが正常であれば,ステータスコード204を返す(下記コード104行目)
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
次にTodoコントローラ用のテストデータを作成します.
$ touch spec/factories/todos.rb
FactoryGirl.define do
factory :todo do
title { Faker::Lorem.word }
created_by { Faker::Number.number(10) }
end
end
加えて,今回のテストをサポートするものを作っていきます.
$ mkdir spec/support && touch spec/support/request_spec_helper.rb
下記のRequestSpecHelperは,JSONファイルで返ってくるものをRubyのハッシュに変換することで,テストしやすくします.
module RequestSpecHelper
# Parse JSON response to ruby hash
def json
JSON.parse(response.body)
end
end
そして,作った上記のヘルパーをRSpecで使うことを宣言します.
# [...]
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
# [...]
RSpec.configure do |config|
# [...]
config.include RequestSpecHelper, type: :request
# [...]
end
さて,ここまででテストする準備が整ったのでテストを実行してみます.
すると,先程Todoコントローラに対して追加した13個のテスト全てで”しっかりと”エラーが起きました.
さぁ,修正を加えて,テストを緑色にしていきましょう.
Todosコントローラの修正
先程の13個のテストがエラーとなった原因は,ルート設定およびTodosコントローラの中身を実装していないからなので,そこら辺の実装をやっていきます.
まずは,ルートを設定していきます.
Rails.application.routes.draw do
resources :todos do
resources :items
end
end
2, 3行目の「resources」でコントローラを指定することで簡単にルーティングが設定できました.なお,itemsはネスト形式で書くことがポイントです.こうすることでtodosコントローラを基に定義したルーティングに属する形でitemsコントローラのルーティングを設定できます.
次に,Todosコントローラの中身です.
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
上記コードでは,「POST /todos」や「GET /todos/:id」に対応したメソッドを定義しています.中身はデータベースのCRUD操作です.
2行目にある「before_action :set_todo, only: [:show, :update, :destroy]」は,アクション実行前に「set_todo」メソッドを実行することを示します.ただし,「only: [:show, :update, :destroy]」によって,show,update,destroyメソッド実行前にのみ「set_todo」を実行するように制限しています.
24, 30行目にある「head :no_content」は,返却するステータスコードを指定しています.「no_content」は204を返します.
さらに,レスポンスをJSONで返すために次のコードを加えます.
module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
さらにさらに,レコードが無いまたは不正な場合にエラーメッセージとステータスコード404を返すために以下のハンドラを追加します.
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
最後に作成したヘルパーをコントローラでincludeします.
class ApplicationController < ActionController::API
include Response
include ExceptionHandler
end
さぁ,今こそテストを実行するときです!
全て緑色になってますね:)
Itemsコントローラ用のテストコード作成
Todosコントローラと同様にItemsコントローラもテスト→コントローラ内部の順で作っていきます.
初めにspecファイルを作成します.
$ touch spec/requests/items_spec.rb
Todosコントローラに対するテスト
Itemsコントローラに対しては以下のテストを行います.
- 「GET /todos/:todo_id/items」を叩いたときTODOが存在すれば,全て(20個)のTODOアイテムを返す(下記コード19行目)
- 「GET /todos/:todo_id/items」を叩いたときリクエストが正常であればステータスコード200を返す(下記コード15行目)
- 「GET /todos/:todo_id/items」を叩いたときTODOが存在しなければ,エラーメッセージを返す(下記コード31行目)
- 「GET /todos/:todo_id/items」を叩いたときTODOが存在しなければ,ステータスコード404を返す(下記コード27行目)
- 「GET /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在すれば,該当するTODOアイテムを返す(下記コード46行目)
- 「GET /todos/:todo_id/items/:id」を叩いたときリクエストが正常であれば,ステータスコード200を返す(下記コード42行目)
- 「GET /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在しなければ,エラーメッセージを返す(下記コード58行目)
- 「GET /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在しなければ,ステータスコード404を返す(下記コード54行目)
- 「POST /todos/:todo_id/items」を叩いたときリクエストが正常であれば,ステータスコード201を返す(下記コード71行目)
- 「POST /todos/:todo_id/items」を叩いたときリクエストが異常であれば,エラーメッセージを返す(下記コード83行目)
- 「POST /todos/:todo_id/items」を叩いたときリクエストが異常であれば,ステータスコード422を返す(下記コード79行目)
- 「PUT /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在すれば,該当するTODOアイテムのnameが「Mozart」と一致するか確認する(下記コード100行目)
- 「PUT /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在すれば,ステータスコード204を返す(下記コード96行目)
- 「PUT /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在しなければ,エラーメッセージを返す(下記コード113行目)
- 「PUT /todos/:todo_id/items/:id」を叩いたときTODOアイテムが存在しなければ,ステータスコード404を返す(下記コード109行目)
- 「DELETE /todos/:id」を叩いたときリクエストが正常であれば,ステータスコード204を返す(下記コード123行目)
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
テストデータも忘れずに作りましょう.
FactoryGirl.define do
factory :item do
name { Faker::StarWars.character }
done false
todo_id nil
end
end
では,テストを実行してみます.
結果は,先程のTodoコントローラのときと同じようにエラーが出ます(16個).
修正を加えて,テストを緑色にしていきましょう.
Itemsコントローラの修正
Itemsコントローラの中身を作っていきます.
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
上記コードでは,Todosコントローラと同様にItemコントローラ用に対応したメソッドを定義しています.中身はtodos_controller.rbとほぼ同じです.
これでテストを実行すると全て緑色になってますよね:)
実食!
以下のAPIを実際に叩いてみます.
利用するツールはcurlやPOSTMAN,HTTPieなどなど,なんでもいいです.今回は参考サイトに合わせてHTTPieを使います.
まずはサーバを起動
$ rails s
下記に示すコマンドを実行して,APIを叩きに行きます.
# 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
# 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
しっかりとレスポンスがきているかは,各自確認してみてください.
これでWEB APIの一通りの開発は終わりです!
おつかれさまでした.
おわりに
RailsでAPIを作成する方法は,今回のRails5の--apiオプション以外にも,rails-apiやgrapeといったGemを利用する方法もあるみたいです.
今後そちらも勉強してみたいと思います.
もしそれらの違いなどについて詳しい方がいればコメントなどで教えていただけると喜びます:)
本記事はほとんど(全て)自分の勉強用のメモですが,だれかの役に立てることを願っています.
ミスなどあればご指摘お願いします.
【注意】
今回参考にした資料では,テスト用データ作成にFactry GirlというGemを利用しました.
しかし,現在,Factry GirlはFactry BotというGem名に変わっています(参考).
したがって,こちらを参考にFactry Botへの移行をおすすめします.
参考
Build a RESTful JSON API With Rails 5 - Part One
追記(2018/11/18):
個人ブログ始めました。
よかったらこちらもどうぞ!