Rails
grape
grape-entity
Rails5

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

はじめに

Rails 5のAPIモードと、Grape、Grape::Entityを用いてWeb APIを開発する手順についてここにまとめておく。
既存のRailsアプリにGrapeによるAPIを追加する場合、下準備の章は飛ばしてもらってかまわない。

検証環境

$ 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 でマウントすれば良い。

参考