読者です 読者をやめる 読者になる 読者になる

RESTful Web API 開発をささえる Garage

技術部の小野(@taiki45)です。この記事では簡単なアプリケーション(ブログシステム)の実装を通して、クックパッドで作成・使用しているライブラリのGarage の紹介と Garage を使った RESTful Web API の開発をご紹介したいと思います。

Garage は RESTful Web API を開発するための、 Rails gemified plugins です。Rails プログラマは Garage を使って Rails を拡張することで素早く Web API を開発することができます。Garage は新しくアプリケーションを開発する場合にも、既存の Rails アプリケーションに組み込んで Web API を実装する場合でも使用できます。Garage はリソースのシリアライズやアクセスコントロールなど Web API の実装に必要な機能をカバーしています。

RubyKaigi2014 にて Garage の OSS 化をお知らせしましたが、実際のアプリケーション開発向けの情報が少ないので、この記事とサンプルアプリケーションを通じて補完したいと思います。

この記事で実装するブログアプリケーションのコードは https://github.com/taiki45/garage-example にあります。

今回実装するアプリケーション

次のようなブログシステムを実装します。

  • アプリケーションが提供するリソースはログインユーザーである user と投稿された投稿である post の2つ。
  • user について以下の操作を提供します
    • ユーザーの一覧の表示 GET /v1/users
    • それぞれのユーザーの情報の表示 GET /v1/users/:user_id
    • 自身の情報の更新 PUT /v1/users/:user_id
  • post については以下の操作を提供します。
    • 新規記事の作成 POST /v1/posts
    • アプリケーション全体の記事の一覧の表示 GET /v1/posts
    • あるユーザーの投稿した記事一覧の表示 GET /v1/users/:user_id/posts
    • それぞれの記事の情報の表示 GET /v1/posts/:post_id
    • 自身の投稿した記事の更新 PUT /v1/posts/:post_id
    • 投稿した記事の削除 DELETE /v1/posts/:post_id
  • user の作成や削除については実装しません。

実際の開発だとクライアントアプリケーションの API 利用の仕方によって、リソースの URL 表現やリソースの関係の表現方法(リソースを埋め込みでレスポンスするかハイパーリンクとしてレスポンスするか)は異なります。今回はこのように設計します。

Rails new

それでは実際にブログアプリケーションを実装していきます。Garage は Rails の gemified plugin なのでアプリケーションの作成について変更する点はありません。

Garage アプリケーションの開発には典型的には RSpec を使用するので、ここでは --skip-testunit フラグを付けて rails new コマンドを実行します。

❯ rails new blog --skip-bundle --skip-test-unit -q && cd blog

開発に必要な gem を追加します。現在 rubygems.org でホストされている garage gem はこの記事で扱っている Garage とは別の gem ですので、Bundler の github shorthand を使って Garage を指定します。

# Gemfile
+gem 'garage', github: 'cookpad/garage'
+
+group :development, :test do
+  gem 'factory_girl_rails', '~> 4.5.0'
+  gem 'pry-rails', '~> 0.3.2'
+  gem 'rspec-rails', '~> 3.1.0'
+end
+

bundle install と rspec helper の設定を行っておきます。

❯ bundle install
❯ bundle exec rails g rspec:install
# spec/rails_helper.rb
-# Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
+Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

# spec/spec_helper.rb
-# The settings below are suggested to provide a good initial experience
-# with RSpec, but feel free to customize to your heart's content.
-=begin
   # These two settings work together to allow you to limit a spec run
   # to individual examples or groups you care about by tagging them with
   # `:focus` metadata. When nothing is tagged with `:focus`, all examples
@@ -81,5 +78,4 @@ RSpec.configure do |config|
   # test failures related to randomization by passing the same `--seed` value
   # as the one that triggered the failure.
   Kernel.srand config.seed
-=end
 end

# spec/support/factory_girl.rb
+RSpec.configure do |config|
+  config.include FactoryGirl::Syntax::Methods
+end

Configuration and authentication/authorization

bundle install や rspec helper の設定を終えた後は、Garage 初期設定と認可ライブラリの Doorkeeper の初期設定をします。

Doorkeeper は Rails アプリケーションに OAuth 2 provider の機能を持たせるための gem です。Garage は Doorkeeper を用いて認可機能を提供します。

クックパッドでは複数の Garage アプリケーションが存在しているので、Doorkeeper を使用せずに、認証認可サーバーへ認証認可を委譲するモジュールを Garage に追加しています。このような Doorkeeper 以外の認可実装や認可を行わない実装への対応は Garage 本体へ追加予定です。

今回実装するアプリケーションでは Doorkeeper を用いて1アプリケーション内で認証認可を完結させます。config/initializers/garage.rb を作成し、Garage と Doorkeeper の設定を追加します。

# config/initializers/garage.rb
+Garage.configure {}
+Garage::TokenScope.configure {}
+
+Doorkeeper.configure do
+  orm :active_record
+  default_scopes :public
+  optional_scopes(*Garage::TokenScope.optional_scopes)
+
+  resource_owner_from_credentials do |routes|
+    User.find_by(email: params[:username])
+  end
+end

# config/routes.rb
 Rails.application.routes.draw do
+  use_doorkeeper
 end

Garage の設定の骨組みと今回のアプリケーション用の Doorkeeper の設定を追加しています。resource_owner_from_credentials メソッドで設定しているのは OAuth2 の Resource Owner Password Credentials Grant を使用した時のユーザーの認証方法です。今回は簡単にするため、パスワード無しでメールアドレスのみを用いて認証を行います。

設定を定義した後は、Doorkeeper が提供する migration を生成して実行しておきます。

❯ bundle exec rails generate doorkeeper:migration
❯ bundle exec rake db:create db:migrate

Start with GET /v1/users

まずはコントローラーやモデルの作成を行って、ユーザー情報の GET ができるところまで進めます。

コントローラーの作成

Rails 標準の ApplicationController あるいはすでに実装されている Rails アプリケーションに Garage を使用する場合はそれに準ずる抽象コントローラークラス(ApiController など)に Garage::ControllerHelper を include します。ControllerHelper は全てのコントローラーに共通の基本的なフィルタとメソッドを提供します。Doorkeeper を使用した認証と認可も行われます。

# app/controllers/application_controller.rb
+
+  include Garage::ControllerHelper
+
+  def current_resource_owner
+    @current_resource_owner ||= User.find(resource_owner_id) if resource_owner_id
+  end
 end

Garage の規約としてここでユーザーが定義すべきものは current_resource_owner というメソッドです。このメソッドはリクエストされたアクセストークンに紐付いているリソースオーナー情報を使用してアプリケーションのユーザーオブジェクトへ変換することが期待されています。注意する点としては、OAuth2 の client credentials などの grant type で認可したアクセストークンについてはリソースオーナー情報が紐つかないので nil が入っていることがあります。ここで変換したユーザーオブジェクトに対して後述するアクセスコントロールロジックが実行されます。

次に普段の Rails アプリケーションと同じような命名規則でユーザーリソースの提供用に UsersController を作成します。routes 設定は普段の Rails アプリケーションと同じです。

# app/controllers/users_controller.rb
+class UsersController < ApplicationController
+  include Garage::RestfulActions
+
+  def require_resources
+    @resources = User.all
+  end
+end

# config/routes.rb
 Rails.application.routes.draw do
   use_doorkeeper
+
+  scope :v1 do
+    resources :users, only: %i(index show update)
+  end
 end

リソースを提供するコントローラーでは Garage::RestfulActions を include して、index/create/show/update/delete それぞれに対応する require_resources/create_resource/require_resource/update_resource/destroy_resource メソッドを定義します。ここでは index に対応する require_resources メソッドを定義しています。Garage::RestfulActions がユーザー定義の require_resources などを使用して実際の action をラップした形で定義してくれます。

ここでは紹介しませんでしたが、他にも Garage はページネーション機能などを提供しています。コントローラーで respond_with_resources_options メソッドをオーバーライドして paginate option を有効にすることで、リソースコレクションの総数や次のページへのリンクなどをレスポンスすることができます。サンプルアプリケーションでは実装しているので、ぜひご覧になってください。

モデルとリソースの定義

ActiveRecord モデルは Rails 標準のやり方で作成します。今回のアプリケーションではユーザーは自由に設定できる名前と認証用の email アドレスを持つことにします。

❯ bundle exec rails g model user name:string email:string
❯ bundle exec rake db:migrate

モデルにリソースとしての定義を追加します。Garage はこの定義を利用してリソースのシリアライゼーションやアクセスコントロールを実行します。

# app/models/user.rb
 class User < ActiveRecord::Base
+  include Garage::Representer
+  include Garage::Authorizable
+
+  property :id
+  property :name
+  property :email
+
+  def self.build_permissions(perms, other, target)
+    perms.permits! :read
+  end
+
+  def build_permissions(perms, other)
+    perms.permits! :read
+    perms.permits! :write
+  end
 end

# config/initializers/garage.rb
 Garage.configure {}
-Garage::TokenScope.configure {}
+Garage::TokenScope.configure do
+  register :public, desc: 'acessing publicly available data' do
+    access :read, User
+    access :write, User
+  end
+end

 Doorkeeper.configure do

Garage::Representer がリソースのエンコーディング・シリアライゼーションを提供するモジュールです。property でリソースが持つ属性を宣言します。他にも link を用いて他のリソースへのリンクを宣言することもできます。詳しくはサンプルアプリケーションを参照してください。

Garage::Authorizable がアクセスコントロール機能を提供するモジュールです。アクセスコントロールについては後述するので、ここではパブリックなリソースとして定義をしておきます。同様に OAuth2 のスコープによるアクセスコントロールについても後述するのでここではパブリックな定義にしておきます。

ローカルサーバーでリクエストを試す

ここまででユーザーリソースの GET を実装したのでローカル環境で実行してみます。

# テストユーザーを作成します
❯ bundle exec rails runner 'User.create(name: "alice", email: "alice@example.com")'
❯ bundle exec rails s

サーバーが起動したら Doorkeeper が提供する OAuth provider の機能を利用してアクセストークンを取得します。http://localhost:3000/oauth/applications を開いてテスト用に OAuth クライアントを作成して client id と client secret を作成して、アクセストークンを発行します。

❯ curl -u "$APPLICTION_ID:$APPLICATION_SECRET" -XPOST http://localhost:3000/oauth/token -d 'grant_type=password&username=alice@example.com'

{"access_token":"XXXX","token_type":"bearer","expires_in":7200,"scope":"public"}

取得したアクセストークンを使って先ほど実装したユーザーリソースを取得します。

❯ curl -s -XGET -H "Authorization: Bearer XXXX" http://localhost:3000/v1/users | jq '.'

[
  {
    "id": 1,
    "name": "alice",
    "email": "alice@example.com"
  }
]

You're done!!

自動テスト

Garage アプリケーションを開発する上で自動テストをセットアップするまでにいくつか注意する点があるので、ここでは request spec を実行するまでのセットアップを紹介します。

Doorkeeper による認証認可をスタブするためのヘルパーを追加します。

# spec/support/request_helper.rb
+require 'active_support/concern'
+
+module RequestHelper
+  extend ActiveSupport::Concern
+
+  included do
+
+    let(:params) { {} }
+
+    let(:env) do
+      {
+        accept: 'application/json',
+        authorization: authorization_header_value
+      }
+    end
+
+    let(:authorization_header_value) { "Bearer #{access_token.token}" }
+
+    let(:access_token) do
+      FactoryGirl.create(
+        :access_token,
+        resource_owner_id: resource_owner.id,
+        scopes: scopes,
+        application: application
+      )
+    end
+
+    let(:resource_owner) { FactoryGirl.create(:user) }
+    let(:scopes) { 'public' }
+    let(:application) { FactoryGirl.create(:application) }
+  end
+end

RSpec example group の中で必要に応じて resource_ownerscopes を上書き定義することで、リソースオーナーの違いや OAuth2 のスコープの違いを作りだせます。

ついでに細かいところですが、facotry の定義を書き換えておきます。

# spec/factories/users.rb
 FactoryGirl.define do
   factory :user do
-    name "MyString"
-email "MyString"
+    sequence(:name) {|n| "user#{n}" }
+    email { "#{name}@example.com" }
   end
-
 end

最初の request spec は最小限のテストのみ実行するようにします。

# spec/requests/users_spec.rb
+require 'rails_helper'
+
+RSpec.describe 'users', type: :request do
+  include RequestHelper
+
+  describe 'GET /v1/users' do
+    let!(:users) { create_list(:user, 3) }
+
+    it 'returns user resources' do
+      get '/v1/users', params, env
+      expect(response).to have_http_status(200)
+    end
+  end
+end

テスト用データベースを作成してテスト実行してみます。

❯ RAILS_ENV=test bundle exec rake db:create migrate
❯ bundle exec rspec -fp spec/requests/users_spec.rb

Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
.

Finished in 0.06393 seconds (files took 1.67 seconds to load)
1 example, 0 failures

無事自動テストのセットアップができました。

よりテストコードを DRY にするには rspec-request_describer gem を導入することもおすすめです。

リソースの保護

実際の Web API ではリソースに対するアクセスコントロールや権限設定が重要になります。具体的には、ユーザーが認可したクライアントにのみプライベートなメッセージの読み書きを許可したり、あるいはブログ記事の編集は投稿者自身にのみ許可する、といった例があります。

Garage では OAuth2 利用したアクセス権の設定をリソース毎に定義することと、リクエストコンテキストを使用してリソース操作に対する権限をリソース毎に定義することで、リソースの保護を実現できます。

アクセスコントロールについては概念自体わかりにくいと思うので、実際に動くアプリケーションで試してみます。サンプルアプリケーションアプリケーションのリビジョン 1b3e35463b87631d1e6acdd08e11ae09cab1b7cc をチェックアウトします。

git clone git@github.com:taiki45/garage-example.git && cd garage-example
git checkout 1b3e35463b87631d1e6acdd08e11ae09cab1b7cc

ここではユーザーリソースに対してリソース操作の権限設定をしてみます。他人のユーザー情報は閲覧できる、他人のユーザー情報は変更できない、という挙動に変更します。テストとしては次のように書けます。

# spec/requests/users_spec.rb
  describe 'PUT /v1/users/:user_id' do
    before { params[:name] = 'bob' }

    context 'with owned resource' do
      let!(:user) { resource_owner }

      it 'updates user resource' do
        put "/v1/users/#{user.id}", params, env
        expect(response).to have_http_status(204)
      end
    end

    context 'without owned resource' do
      let!(:other) { create(:user, name: 'raymonde') }

      it 'returns 403' do
        put "/v1/users/#{other.id}", params, env
        expect(response).to have_http_status(403)
      end
    end
  end

テストが失敗することを確かめます。

❯ bundle exec rspec spec/requests/users_spec.rb:24

users
  PUT /v1/users/:user_id
    with owned resource
      updates user resource
    without owned resource
      returns 403 (FAILED - 1)

ユーザーリソースのパーミッション組み立てロジックを変更します。other はリクエストにおけるリソースオーナーが束縛されます。リソースオーナーは先ほど ApplicationController で実装した current_resource_owner メソッドで変換されたアプリケーションのユーザーオブジェクトが束縛されているので、今回のアプリケーションだと User クラスのインスタンスです。

# app/models/user.rb
   def build_permissions(perms, other)
     perms.permits! :read
-    perms.permits! :write
+    perms.permits! :write if self == other
   end

テストを実行してみます。

❯ bundle exec rspec spec/requests/users_spec.rb:24

users
  PUT /v1/users/:user_id
    without owned resource
      returns 403
    with owned resource
      updates user resource

他人のユーザー情報は更新できないようにできました。

Garage は他にもブログの下書き投稿は投稿者しか閲覧できない、ユーザーの名前の変更は特定のスコープがないと変更できない、など様々な権限設定ができます。ここでは紹介しませんでしたが、アクセス権の設定やいくつかの拡張機能についてはサンプルアプリケーションとドキュメントを参照してください。

Response matcher

JSON API のレスポンスのテストは RSpec2 では rspec-json_matcher を用いて、RSpec3 では composing-matchers を使用して記述します。テストによっては構造を検査するだけでなく、実際のレスポンスされた値を検査します。

RSpec2

  let(:post_structure) do
    {
      'id' => Integer,
      'title' => String,
      'body' => String,
      'published_at' => String
    }
  end

  describe 'GET /v1/posts/:post_id' do
    let!(:post) { create(:post, user: resource_owner) }

    it 'returns post resource' do
      get "/v1/posts/#{post.id}", params, env
      response.status.should == 200
      response.body.should be_json_as(post_structure)
    end
  end

RSpec3

  let(:post_structure) do
    {
      'id' => a_kind_of(Integer),
      'title' => a_kind_of(String),
      'body' => a_kind_of(String).or(a_nil_value),
      'published_at' => a_kind_of(String).or(a_nil_value)
    }
  end

  describe 'GET /v1/posts/:post_id' do
    let!(:post) { create(:post, user: resource_owner) }

    it 'returns post resource' do
      get "/v1/posts/#{post.id}", params, env
      expect(response).to have_http_status(200)
      expect(JSON(response.body)).to match(post_structure)
    end
  end

DebugExceptions

Rails はデフォルトの設定だと development 環境ではサーバーエラーが起きた場合、 ActionDispatch::DebugExceptions がエラー情報を HTML でレスポンスします。JSON API 開発の文脈ではデバッグ用のエラーレスポンスも JSON のほうが都合が良いです。その場合 debug_exceptions_json gem を使います。エラー情報が JSON でレスポンスされるので、開発がしやすくなります。また、RSpec との連携機能があり、request spec の実行中にサーバーエラーが起きると RSpec のフォーマッタを利用してエラー情報をダンプしてくれます。

Failures:

  1) server error dump when client accepts application/json with exception raised responses error json
     Failure/Error: expect(response).to have_http_status(200)
       expected the response to have status code 200 but it was 500
     # ./spec/features/server_error_dump_spec.rb:21:in `block (4 levels) in <top (required)>'

     ServerErrorDump:
       exception class:
         HelloController::TestError
       message:
         test error
       short_backtrace:
         <backtrace is here>

APIドキュメント

Web API 開発の文脈では、API のドキュメントを提供しドキュメントを最新の状態にアップデートしておくことで開発中のコミュニケーションを効率化できます。

API ドキュメントの生成には autodoc gem を使っています。リクエスト例、レスポンス例だけでなく、weak_parameters gem と組み合わせることでリクエストパラメータについてもドキュメント化できます。生成されたドキュメントは Garage のドキュメント提供機能を使用してブラウザで閲覧できるようにすることもできますし、より簡単には markdown フォーマットで生成されるので Github 上でレンダーされたドキュメントを参照してもらうこともできます。

f:id:aladhi:20141105172010p:plain


Garage を使用した RESTful Web API の開発についてご紹介しました。コントローラーを作る、リソースを定義する、アクセスコントロールを定義する、このステップを繰り返すことでアプリケーションを開発することができます。

この記事が Garage を使用したアプリケーション実装の参考になれば幸いです。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/