89
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-12-03

2020-08-13 追記

この記事はRails 5を対象としていますが、Rails 6でもだいたい同じです。
ただ、Rails 6からautoloadの仕組みとして採用されたzeitwerkモードの仕様によって、 Base::API クラスを正しく読み込めないようです。
Rails 6をお使いの場合は、記事内の Base::APIBase::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::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 でマウントすれば良い。

参考

89
62
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
89
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?