Rails 5 APIモード + Grape + Grape::Entityで作るWeb API


はじめに

Rails 5のAPIモードと、Grape、Grape::Entityを用いてWeb APIを開発する手順についてここにまとめておく。

既存のRailsアプリにGrapeによるAPIを追加する場合、下準備の章は飛ばしてもらってかまわない。

この記事で作ったWeb APIにトークンベース認証の機構を追加したい場合はこちら。

Rails 5 APIモード + GrapeによるAPIにかんたんなトークンベース認証の機構を追加する - Qiita


検証環境

$ sw_vers

ProductName: Mac OS X
ProductVersion: 10.14.1
BuildVersion: 18B75

$ ruby -v
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-darwin18]

$ rails -v
Rails 5.2.1


下準備


プロジェクトの作成

$ rails new grape-on-rails-api --api --database postgresql -T



  • --api でAPIモードを指定。


  • --database はお好きなものを。この記事では、Herokuにデプロイすることも考えてPostgreSQLを指定する。


  • -T で テスト関連のファイルの生成をスキップする。


DBの作成とマイグレーション

$ rails db:create db:migrate


Rack::CORSの初期設定

Gemfile の29行目あたりのコメントアウトを外す。

# Gemfile

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'

bundle install 実行

$ bundle install

config/initializers/cors.rb を編集。コメントアウトされた部分の # を外し、 origins* を指定する。

# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'

resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end

CORS(Cross-Origin Resource Sharing)についてはこの辺を参照。

オリジン間リソース共有 (CORS) - HTTP | MDN

要するに、クロスドメイン制約というセキュリティ上の理由でサーバと通信できるドメインやHTTPヘッダのフィールド、メソッドを絞る機構があるのだけど、APIサーバでそれをやられると困るので、全部許可しておくという設定をしたということだ。


データの準備

サンプルとして扱うデータは何でもいいのだけど、この記事では著者(Author)と本(Book)にしてみる。

$ rails g model Author name:string

$ rails g model Book title:string price:integer author:references

マイグレーションファイルを編集する。細かい解説はこの記事の本質ではないので省略。

# db/migrate/20181202145953_create_authors.rb

class CreateAuthors < ActiveRecord::Migration[5.2]
def change
create_table :authors do |t|
t.string :name, null: false

t.timestamps
end
add_index :authors, :name
end
end

# db/migrate/20181202150014_create_books.rb

class CreateBooks < ActiveRecord::Migration[5.2]
def change
create_table :books do |t|
t.string :title, null: false
t.integer :price, null: false
t.references :author, foreign_key: true

t.timestamps
end
add_index :books, [ :author_id, :title ], unique: true
end
end

Model側も編集する。各種バリデーションと関連付けを忘れずに。

# app/models/author.rb

class Author < ApplicationRecord
has_many :books

validates :name, presence: true
end

# app/models/book.rb

class Book < ApplicationRecord
belongs_to :author

validates :title, presence: true
validates :price, presence: true
validates :author_id, uniqueness: { scope: :title }
end

最後に、適当なデータをDBに流し込む。

# rails console

nozaki_mado = Author.create(name: '野崎まど')
Book.create(title: 'know', price: 720, author: nozaki_mado)
Book.create(title: '野崎まど劇場', price: 610, author: nozaki_mado)

jason_fried = Author.create(name: 'ジェイソン・フリード')
Book.create(title: '小さなチーム、大きな仕事', price: 640, author: jason_fried)
Book.create(title: '強いチームはオフィスを捨てる', price: 1500, author: jason_fried)

これで下準備は完了だ。


APIの方針

APIの定義には Grape を利用し、シリアライザ(普通のRailsアプリでいうView的な役割を持つ?) として Grape::Entity を利用する。

また、ルートエンドポイントは http://localhost:3000/v1/ とする。

APIのディレクトリ構成はこんな感じ。

app/api/base/api.rb に後述する Base::API を定義し、それを config/routes.rb 内でマウントするという方針を取る。

app/api

├── base
│   └── api.rb
└── v1
├── entities
│   ├── author_entity.rb
│   └── book_entity.rb
├── authors.rb
├── books.rb
└── root.rb


Grapeの導入


Gemfileの編集とインストール

Gemfile に追記。

gem 'grape'

gem 'grape-entity'

bundle install実行

$ bundle install


初期設定

app/api ディレクトリと、その他の基本的なディレクトリを作る。

$ mkdir app/api

$ mkdir app/api/base
$ mkdir app/api/v1


Base::API の作成とマウント

app/api/base/api.rb を作成。

# app/api/base/api.rb

module Base
class API < Grape::API
# あとあと↓のようにしてAPIをマウントするが、今はV1::Rootが無いのでコメントアウトしておく。
# mount V1::Root
end
end

Base::APIconfig/routes.rb にてマウント。

# config/routes.rb

Rails.application.routes.draw do
mount Base::API => '/'
end

rails routesコマンドで確認。base_apiの項目があればOKだ。

$ rails routes

app ディレクトリ以下にあるファイルはRailsが自動で読み込んでくれるはずであるが、うまくいかない場合は以下のコマンドでspringを停止し、cacheをクリアしてから実行すると良い。

$ spring stop

$ rails routes


V1::Rootの作成とマウント

APIの実装は app/api/v1 ディレクトリ以下に置いていくことになる。まずは、 Base::API からマウントするためのrootファイルを作る。

また、 V1::Root 内で、各APIをマウントするのを忘れないようにする。

# app/api/v1/root.rb

module V1
class Root < Grape::API
version :v1
format :json

mount V1::Authors
mount V1::Books
end
end

V1::Root を作ったので、 Base::API のコメントアウト解除も忘れずに。

# app/api/base/api.rb

module Base
class API < Grape::API
mount V1::Root # ←コメントアウト解除
end
end


APIの実装

いよいよAPIの実装を書いていく。基本的なロジックは、通常のControllerを書く時と同じだ。

# app/api/v1/authors.rb

module V1
class Authors < Grape::API
resources :authors do
desc 'returns all authors'
get '/' do
@authors = Author.all # 最後に評価された値がレスポンスとして返される
end

desc 'returns an author'
params do
requires :id, type: Integer
end
get '/:id' do
@author = Author.find(params[:id])
end

desc 'Create an author'
params do
requires :name, type: String
end
post '/' do
@author = Author.create(name: params[:name])
end

desc 'Delete an author'
params do
requires :id, type: Integer
end
delete '/:id' do
@author = Author.find(params[:id])
@author.destroy
end
end
end
end

# app/api/v1/books.rb

module V1
class Books < Grape::API
resources :books do
desc 'returns all books'
get '/' do
@books = Book.all
end

desc 'returns a book'
params do
requires :id, type: Integer
end
get '/:id' do
@book = Book.find(params[:id])
end

desc 'Create a book'
params do
requires :title, type: String
requires :price, type: Integer
requires :author_id, type: Integer
end
post '/' do
@book = Book.create(
title: params[:title],
price: params[:price],
author_id: params[:author_id]
)
end
end
end
end


動作確認

curlコマンド等でレスポンスを確認する。

$ curl http://localhost:3000/v1/books

[
{
"id": 1,
"title": "know",
"price": 720,
"author_id": 1,
"created_at": "2018-12-02T15:13:08.040Z",
"updated_at": "2018-12-02T15:13:08.040Z"
},
{
"id": 2,
"title": "野崎まど劇場",
"price": 610,
"author_id": 1,
"created_at": "2018-12-02T15:15:43.926Z",
"updated_at": "2018-12-02T15:15:43.926Z"
},
{
"id": 3,
"title": "小さなチーム、大きな仕事",
"price": 640,
"author_id": 3,
"created_at": "2018-12-02T15:17:08.821Z",
"updated_at": "2018-12-02T15:17:08.821Z"
},
{
"id": 4,
"title": "強いチームはオフィスを捨てる",
"price": 1500,
"author_id": 3,
"created_at": "2018-12-02T15:17:49.291Z",
"updated_at": "2018-12-02T15:17:49.291Z"
}
]

$ curl http://localhost:3000/v1/books/1

{
"id": 1,
"title": "know",
"price": 720,
"author_id": 1,
"created_at": "2018-12-02T15:13:08.040Z",
"updated_at": "2018-12-02T15:13:08.040Z"
}

$ curl -X 'POST' -H 'Content-Type:application/json' -d '{"name": "HIKAKIN"}' http://localhost:3000/v1/authors

{
"id": 3,
"name": "HIKAKIN",
"created_at": "2018-12-03T02:17:33.213Z",
"updated_at": "2018-12-03T02:17:33.213Z"
}


Grape::Entityの導入

現状のAPIは、すべての属性を返しているだけだが、実際には「このカラムはユーザには見せたくない」とか、「本の価格は税別価格をDBに格納しておいて、ユーザにデータを返す時は税込価格がいい」という状況があるだろう。


Entityの定義

Entityを定義する。この記事では、Entityは app/api/v1/entities/ 以下に *_entity.rb という名前で配置する。

まずは app/api/v1/entities ディレクトリを作成する。

$ mkdir app/api/v1/entities

AuthorとBookのEntityを作成。

# app/api/v1/entities/author_entity.rb

module V1
module Entities
class AuthorEntity < Grape::Entity
expose :id # レスポンスに含めたい属性を expose
expose :name
end
end
end

# app/api/v1/entities/book_entity.rb

module V1
module Entities
class BookEntity < Grape::Entity
expose :id
expose :title
expose :price
expose :tax_included_price do |instance, options| # ブロックを渡す事ができる。
instance.price * 1.08 # 実際は四捨五入したり、消費税率を定数として持っておくなどすべき
end
expose :author, using: V1::Entities::AuthorEntity
end
end
end


APIの変更

定義したEntityを使用するには、 present @instance, with: EntityName と指定する。

# app/api/v1/authors.rb

module V1
class Authors < Grape::API
resources :authors do
desc 'returns all authors'
get '/' do
@authors = Author.all
present @authors, with: V1::Entities::AuthorEntity # @authors を V1::Entities::AuthorEntityを使用して返却する
end

desc 'returns an author'
params do
requires :id, type: Integer
end
get '/:id' do
@author = Author.find(params[:id])
present @author, with: V1::Entities::AuthorEntity
end

desc 'Create an author'
params do
requires :name, type: String
end
post '/' do
@author = Author.new(name: params[:name])

# 作成の成否によってレスポンスを分ける
if @author.save
status 201
present @author, with: V1::Entities::AuthorEntity
else
status 400
present @author.errors.full_messages
end
end

desc 'Delete an author'
params do
requires :id, type: Integer
end
delete '/:id' do
@author = Author.find(params[:id])

# 削除の成否によってレスポンスを分ける
if @author.destroy
status 204
present nil
else
status 400
present @author.errors.full_messages
end
end
end
end
end

# app/api/v1/books.rb

module V1
class Books < Grape::API
resources :books do
desc 'returns all books'
get '/' do
@books = Book.all
present @books, with: V1::Entities::BookEntity
end

desc 'returns a book'
params do
requires :id, type: Integer
end
get '/:id' do
@book = Book.find(params[:id])
present @book, with: V1::Entities::BookEntity
end

desc 'Create a book'
params do
requires :title, type: String
requires :price, type: Integer
requires :author_id, type: Integer
end
post '/' do
@book = Book.new(
title: params[:title],
price: params[:price],
author_id: params[:author_id]
)

if @book.save
status 201
present @book, with: V1::Entities::BookEntity
else
status 400
present @book.errors
end
end
end
end
end


動作確認

レスポンスを確認するとこんな感じ。

$ curl http://localhost:3000/v1/books

[
{
"id": 1,
"title": "know",
"price": 720,
"tax_included_price": 777.6,
"author": {
"id": 1,
"name": "野崎まど"
}
},
{
"id": 2,
"title": "野崎まど劇場",
"price": 610,
"tax_included_price": 658.8000000000001,
"author": {
"id": 1,
"name": "野崎まど"
}
},
{
"id": 3,
"title": "小さなチーム、大きな仕事",
"price": 640,
"tax_included_price": 691.2,
"author": {
"id": 3,
"name": "ジェイソン・フリード"
}
},
{
"id": 4,
"title": "強いチームはオフィスを捨てる",
"price": 1500,
"tax_included_price": 1620,
"author": {
"id": 3,
"name": "ジェイソン・フリード"
}
}
]

$ curl http://localhost:3000/v1/books/1

{
"id": 1,
"title": "know",
"price": 720,
"tax_included_price": 777.6,
"author": {
"id": 1,
"name": "野崎まど"
}
}

$ curl http://localhost:3000/v1/authors

[
{
"id": 1,
"name": "野崎まど"
},
{
"id": 2,
"name": "ジェイソン・フリード"
},
{
"id": 3,
"name": "HIKAKIN"
}
]

$ curl -X 'POST' -H 'Content-Type:application/json' -d '{"title": "僕の仕事は YouTube", "price": 952, "author_id": 3}' http://localhost:3000/v1/books

{
"id":5,
"title":"僕の仕事は YouTube",
"price":952,
"tax_included_price":1028.16,
"author":{
"id":3,
"name":"HIKAKIN"
}
}


おわりに

これで、Rails 5 APIモード + Grape + Grape::Entityで基本的なWeb APIを実装することが出来た。

また新しくAPIのエンドポイントを追加したい場合は、 app/api/v1 以下に新しいファイルを作り、必要に応じてEntityを定義した後、 app/api/v1/root.rb でマウントすれば良い。


参考