Grape で Web API 開発

Grape は RESTful な API を構築するためのマイクロフレームワークです。
今回は Grape を使って簡単な Web API を作っていきます。

intridea/grape · GitHub

[2015/03/07] ファイルの配置を変更しました。変更点は GitHub をご確認ください。

準備

$ rails g model MessageBoard title:string body:text
$ rails g model Comment message_board_id:integer body:text
$ rake db:migrate
$ rails g rspec:install
# app/models/message_board.rb

class MessageBoard < ActiveRecord::Base
  has_many :comments
end

API 配置用のディレクトリ作成

$ mkdir -p app/apis/api
$ mkdir -p app/apis/entity

Grape gemのインストール

# Gemfile

gem 'grape'
gem 'grape-jbuilder'
$ bundle install

APIの実装

ディレクトリ構成とルーティングは以下のようになる予定です。

├── app
│   ├── apis
│   │   └── api
│   │       ├── base.rb
│   │       ├── v1
│   │       │   ├── base.rb
│   │       │   ├── comments.rb
│   │       │   └── message_boards.rb
│   │       └── v2
│   │           ├── base.rb
│   │           ├── comments.rb
│   │           └── message_boards.rb
│   └── views
│       ├── apis
│       │   └── api
│       │       ├── v1
│       │       │   ├── comments
│       │       │   │   ├── _comment.jbuilder
│       │       │   │   └── index.jbuilder
│       │       │   └── message_boards
│       │       │       ├── _message_board.jbuilder
│       │       │       ├── index.jbuilder
│       │       │       └── show.jbuilder
│       │       └── v2
│       │           ├── comments
│       │           │   ├── _comment.jbuilder
│       │           │   └── index.jbuilder
│       │           └── message_boards
│       │               ├── _message_board.jbuilder
│       │               ├── index.jbuilder
│       │               └── show.jbuilder
Verb URI Pattern
GET /api/v1/message_boards
POST /api/v1/message_boards
GET /api/v1/message_boards/:id
PUT /api/v1/message_boards/:id
DELETE /api/v1/message_boards/:id
GET /api/v1/message_boards/:message_board_id/comments
POST /api/v1/message_boards/:message_board_id/comments
DELETE /api/v1/message_boards/:message_board_id/comments/:id
GET /api/v2/message_boards
POST /api/v2/message_boards
GET /api/v2/message_boards/:id
PUT /api/v2/message_boards/:id
DELETE /api/v2/message_boards/:id
GET /api/v2/message_boards/:message_board_id/comments
POST /api/v2/message_boards/:message_board_id/comments
DELETE /api/v2/message_boards/:message_board_id/comments/:id

APIのルーティング追加

# config/routes.rb

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

app/apiをオートロードに追加する設定

# config/application.rb

module GrapeExample
  class Application < Rails::Application
    config.paths.add File.join('app', 'apis'), glob: File.join('**', '*.rb')
    config.autoload_paths += Dir[Rails.root.join('app', 'apis', '*')]

    config.middleware.use(Rack::Config) do |env|
      env['api.tilt.root'] = Rails.root.join 'app', 'views', 'apis'
    end
  end
end

ルーツ用のタスク

いつも通りrake routesしただけだとAPIで定義したルーティングが表示されないのでタスクを作成します。

# lib/tasks/routes.rake

namespace :api do
  desc 'API Routes'
  task routes: :environment do
    API::Base.routes.each do |api|
      method = api.route_method.ljust(10)
      path = api.route_path.gsub(':version', api.route_version)
      puts "     #{method} #{path}"
    end
  end
end

「rake api:routes」でAPIのルーティングが確認できます。

Grape

# app/apis/api/base.rb

module API
  class Base < Grape::API
    mount V1::Base
    mount V2::Base
  end
end
# app/apis/api/v1/base.rb

module API
  module V1
    class Base < Grape::API
      format :json
      default_format :json

      # for Grape::Jbuilder
      formatter :json, Grape::Formatter::Jbuilder

      prefix :api # /apiというパスになる
      version 'v1', using: :path # /api/v1というパスになる

      # 例外ハンドル 404
      rescue_from ActiveRecord::RecordNotFound do |e|
        rack_response({ message: e.message, status: 404 }.to_json, 404)
      end

      # 例外ハンドル 400
      rescue_from Grape::Exceptions::ValidationErrors do |e|
        rack_response e.to_json, 400
      end

      # 例外ハンドル 500
      rescue_from :all do |e|
        if Rails.env.development?
          raise e
        else
          error_response(message: "Internal server error", status: 500)
        end
      end

      mount V1::MessageBoards
      mount V1::Comments
    end
  end
end
# app/apis/api/v1/message_boards.rb

module API
  module V1
    class MessageBoards < Grape::API
      helpers do
        # Strong Parametersの設定
        def message_board_params
          ActionController::Parameters.new(params).permit(:title, :body)
        end

        def set_message_board
          @message_board = MessageBoard.find(params[:id])
        end

        # パラメータのチェック
        # パラメーターの必須、任意を指定することができる。
        # use :attributesという形で使うことができる。
        params :attributes do
          requires :title, type: String, desc: "MessageBoard title."
          optional :body, type: String, desc: "MessageBoard body."
        end

        # パラメータのチェック
        params :id do
          requires :id, type: Integer, desc: "MessageBoard id."
        end
      end

      resource :message_boards do
        desc 'GET /api/v1/message_boards'
        get '/', jbuilder: 'api/v1/message_boards/index' do
          @message_boards = MessageBoard.all
        end

        desc 'POST /api/v1/message_boards'
        params do
          use :attributes
        end
        post '/' do
          message_board = MessageBoard.new(message_board_params)
          message_board.save
        end

        desc 'GET /api/v1/message_boards/:id'
        params do
          use :id
        end
        get '/:id', jbuilder: 'api/v1/message_boards/show' do
          set_message_board
        end

        desc 'PUT /api/v1/message_boards/:id'
        params do
          use :id
          use :attributes
        end
        put '/:id' do
          set_message_board
          @message_board.update(message_board_params)
        end

        desc 'DELETE /api/v1/message_boards/:id'
        params do
          use :id
        end
        delete '/:id' do
          set_message_board
          @message_board.destroy
        end
      end
    end
  end
end
# app/apis/api/v1/comments.rb

module API
  module V1
    class Comments < Grape::API
      helpers do
        # Strong Parametersの設定
        def comment_params
          ActionController::Parameters.new(params).permit(:body)
        end

        def set_message_board
          @message_board = MessageBoard.find(params[:message_board_id])
        end

        def set_comment
          @comment = @message_board.comments.find(params[:id])
        end

        # パラメータのチェック
        params :attributes do
          requires :body, type: String, desc: "MessageBoard body."
        end

        # パラメータのチェック
        params :message_board_id do
          requires :message_board_id, type: Integer, desc: "MessageBoard id."
        end

        # パラメータのチェック
        params :id do
          requires :id, type: Integer, desc: "MessageBoard id."
        end
      end

      resource :message_boards do
        params do
          use :message_board_id
        end

        route_param :message_board_id do
          resource :comments do
            desc 'GET /api/v1/message_boards/:message_board_id/comments'
            get '/', jbuilder: 'api/v1/comments/index' do
              set_message_board
              @comments = @message_board.comments
            end

            desc 'POST /api/v1/message_boards/:message_board_id/comments'
            params do
              use :attributes
            end
            post do
              set_message_board
              @message_board.comments.create(comment_params)
            end

            desc "DELETE /api/v1/message_boards/:message_board_id/comments/:id"
            params do
              use :id
            end
            delete '/:id' do
              set_message_board
              set_comment
              @comment.destroy
            end
          end
        end
      end
    end
  end
end

Jbuilder

今回はJbuilderを使ってJSONフォーマットを作っていきます。

# app/views/apis/api/v1/message_boards/index.jbuilder

json.message_boards @message_boards do |message_board|
  json.partial! 'api/v1/message_boards/message_board', message_board: message_board

  json.comments message_board.comments do |comment|
    json.partial! 'api/v1/comments/comment', comment: comment
  end

  json.comment_count message_board.comments.count
end
# app/views/apis/api/v1/message_boards/show.jbuilder

json.message_board do
  json.partial! 'api/v1/message_boards/message_board', message_board: @message_board
end
# app/views/apis/api/v1/message_boards/_message_board.jbuilder

json.extract! message_board, :id, :title, :body, :updated_at
# app/views/apis/api/v1/comments/index.jbuilder

json.comments @comments do |comment|
  json.partial! 'api/v1/comments/comment', comment: comment
end
# app/views/apis/api/v1/comments/_comment.jbuilder

json.extract! comment, :id, :body, :updated_at

RSpec

もちろんテストも書きました。

# spec/apis/api/v1/message_boards_spec.rb

require 'rails_helper'

RSpec.describe API::V1::MessageBoards, type: :request do
  describe 'GET /api/v1/message_boards' do
    it 'responds successfully' do
      FactoryGirl.create_list(:message_board, 2)
      get '/api/v1/message_boards'
      expect(response).to be_success
      expect(response.status).to eq(200)
    end
  end

  describe 'POST /api/v1/message_boards' do
    let(:path) { '/api/v1/message_boards' }
    let(:attributes) { FactoryGirl.attributes_for(:message_board) }

    describe 'validations' do
      context 'when title not exist in params' do
        let(:attributes) { FactoryGirl.attributes_for(:message_board).except(:title) }
        it  do
          post path, attributes

          expect(response).not_to be_success
          expect(response.status).to eq(400)
        end
      end
    end

    it 'responds successfully' do
      post path, attributes

      expect(response).to be_success
      expect(response.status).to eq(201)
    end

    it 'creates a new MessageBoard' do
      expect {
        post path, attributes
      }.to change(MessageBoard, :count).by(1)
    end
  end

  describe 'GET /api/v1/message_boards/:id' do
    let(:message_board) { FactoryGirl.create(:message_board) }

    it 'responds successfully' do
      get "/api/v1/message_boards/#{message_board.id}"
      expect(response).to be_success
      expect(response.status).to eq(200)
    end
  end

  describe 'PUT /api/v1/message_boards/:id' do
    let(:message_board) { FactoryGirl.create(:message_board) }
    let(:attributes) { FactoryGirl.attributes_for(:message_board, title: changed_title) }
    let(:changed_title) { 'changed title' }
    let(:path) { "/api/v1/message_boards/#{message_board.id}" }

    describe 'validations' do
      context 'when title not exist in params' do
        let(:attributes) { FactoryGirl.attributes_for(:message_board).except(:title) }
        it  do
          put path, attributes

          expect(response).not_to be_success
          expect(response.status).to eq(400)
        end
      end
    end

    it 'responds successfully' do
      put path, attributes
      expect(response).to be_success
      expect(response.status).to eq(200)
    end

    it 'updates MessageBoard' do
      put path, attributes
      expect(message_board.reload.title).to eq(changed_title)
    end
  end

  describe 'DELETE /api/v1/message_boards/:id' do
    let(:message_board) { FactoryGirl.create(:message_board) }

    it 'deletes a MessageBoard' do
      message_board
      expect {
        delete "/api/v1/message_boards/#{message_board.id}"
      }.to change(MessageBoard, :count).by(-1)
    end
  end
end
# spec/apis/api/v1/comments_spec.rb

require 'rails_helper'

require 'rails_helper'

RSpec.describe API::V1::Comments, type: :request do
  let(:message_board) { FactoryGirl.create(:message_board) }

  describe 'GET /api/v1/message_boards/:message_board_id/comments' do
    it 'responds successfully' do
      FactoryGirl.create_list(:comment, 2, message_board_id: message_board.id)
      get "/api/v1/message_boards/#{message_board.id}/comments"
      expect(response).to be_success
      expect(response.status).to eq(200)
    end
  end

  describe 'POST /api/v1/message_boards/:message_board_id/comments' do
    let(:path) { "/api/v1/message_boards/#{message_board.id}/comments" }
    let(:attributes) { FactoryGirl.attributes_for(:comment) }

    describe 'validations' do
      context 'when title not exist in params' do
        let(:attributes) { FactoryGirl.attributes_for(:comment).except(:body) }
        it  do
          post path, attributes

          expect(response).not_to be_success
          expect(response.status).to eq(400)
        end
      end
    end

    it 'responds successfully' do
      post path, attributes

      expect(response).to be_success
      expect(response.status).to eq(201)
    end

    it 'creates a new Comment' do
      expect {
        post path, attributes
      }.to change(Comment, :count).by(1)
    end
  end

  describe 'DELETE /api/v1/message_boards/:message_board_id/comments/:id' do
    let(:comment) { FactoryGirl.create(:comment, message_board_id: message_board.id) }

    before { comment }

    it 'deletes a Comment' do
      expect {
        delete "/api/v1/message_boards/#{message_board.id}/comments/#{comment.id}"
      }.to change(Comment, :count).by(-1)
    end
  end
end

今回作ったもの

kzy52/grape-example · GitHub

関連する記事

JSONの生成に Jbuilder ではなく Grape::Entity を使ってみました。
こちらの方が個人的には好きです。

Grape::Entity の使い方 - kzy52's blog

参考

CODETUNES · Introduction to building APIs with Grape

Building RESTful API using Grape in Rails | Fun On Rails