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

## 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の壊し方](https://qiita.com/fursich/items/717a720d9f4465e4cbbb#a%E5%A4%A7%E6%96%87%E5%AD%97%E7%95%A5%E8%AA%9Eacronym%E3%82%92%E4%BD%BF%E3%81%84%E3%81%9F%E3%81%84%E5%A0%B4%E5%90%88) の `config/initializers/zeitwerk.rb` ）

## はじめに

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

この記事で作ったWeb APIにトークンベース認証の機構を追加したい場合はこちら。
[Rails 5 APIモード + GrapeによるAPIにかんたんなトークンベース認証の機構を追加する - Qiita](https://qiita.com/mktakuya/items/d729ecd64c2bd8cea6e8)

## 検証環境

```shell-session
$ 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
```


## 下準備

### プロジェクトの作成

```shell-session
$ rails new grape-on-rails-api --api --database postgresql -T
```

- `--api` でAPIモードを指定。
- `--database` はお好きなものを。この記事では、Herokuにデプロイすることも考えてPostgreSQLを指定する。
- `-T` で テスト関連のファイルの生成をスキップする。


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

```shell-session
$ rails db:create db:migrate
```


### Rack::CORSの初期設定

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

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

`bundle install` 実行

```shell-session
$ bundle install
```

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

```ruby
# 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](https://developer.mozilla.org/ja/docs/Web/HTTP/HTTP_access_control)

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


### データの準備

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

```shell-session
$ rails g model Author name:string
$ rails g model Book title:string price:integer author:references
```

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

```ruby
# 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

```

```ruby
# 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側も編集する。各種バリデーションと関連付けを忘れずに。

```ruby
# app/models/author.rb
class Author < ApplicationRecord
  has_many :books

  validates :name, presence: true
end
```

```ruby
# 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に流し込む。

```ruby
# 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](https://github.com/ruby-grape/grape) を利用し、シリアライザ（普通のRailsアプリでいうView的な役割を持つ？） として [Grape::Entity](https://github.com/ruby-grape/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` に追記。

```ruby
gem 'grape'
gem 'grape-entity'
```

bundle install実行

```shell
$ bundle install
```

### 初期設定

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

```shell-session
$ mkdir app/api
$ mkdir app/api/base
$ mkdir app/api/v1
```

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

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

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

`Base::API` を `config/routes.rb` にてマウント。

```ruby
# config/routes.rb
Rails.application.routes.draw do
  mount Base::API => '/'
end
```

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

```shell-session
$ rails routes
```

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

```shell-session
$ spring stop
$ rails routes
```


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

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

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

```ruby
# 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` のコメントアウト解除も忘れずに。

```ruby
# app/api/base/api.rb
module Base
  class API < Grape::API
    mount V1::Root # ←コメントアウト解除
  end
end
```


### APIの実装

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

```ruby
# 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
```

```ruby
# 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コマンド等でレスポンスを確認する。

```shell-session
$ 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"
  }
]
```

```shell-session
$ 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"
}
```

```shell-session
$ 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` ディレクトリを作成する。

```shell-session
$ mkdir app/api/v1/entities
```

AuthorとBookのEntityを作成。

```ruby
# app/api/v1/entities/author_entity.rb
module V1
  module Entities
    class AuthorEntity < Grape::Entity
      expose :id # レスポンスに含めたい属性を expose
      expose :name
    end
  end
end
```

```ruby
# 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` と指定する。

```ruby
# 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
```

```ruby
# 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
```


### 動作確認

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

```shell-session
$ 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": "ジェイソン・フリード"
    }
  }
]
```

```shell-session
$ curl http://localhost:3000/v1/books/1
{
  "id": 1,
  "title": "know",
  "price": 720,
  "tax_included_price": 777.6,
  "author": {
    "id": 1,
    "name": "野崎まど"
  }
}
```

```shell-session
$ curl http://localhost:3000/v1/authors
[
  {
    "id": 1,
    "name": "野崎まど"
  },
  {
    "id": 2,
    "name": "ジェイソン・フリード"
  },
  {
    "id": 3,
    "name": "HIKAKIN"
  }
]
```

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


## 参考

- [ruby-grape/grape](https://github.com/ruby-grape/grape)
- [ruby-grape/grape-entity](https://github.com/ruby-grape/grape-entity)