2020-08-13 追記
この記事はRails 5を対象としていますが、Rails 6でもだいたい同じです。
ただ、Rails 6からautoloadの仕組みとして採用されたzeitwerkモードの仕様によって、 Base::API
クラスを正しく読み込めないようです。
Rails 6をお使いの場合は、記事内の Base::API
を Base::Api
と読替えるか、 ActiveSupport::Infector
の設定を上書きして "api".camelize
の結果が "API"
となるようにしてやってください。(参考: Zeitwerkの壊し方 の config/initializers/zeitwerk.rb
)
はじめに
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::API
を config/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
でマウントすれば良い。