社内勉強会でスキーマファースト開発についてしゃべった

2017-10-24(火)にペパボEC事業部において「EC事業部 TechMTG #4」という社内勉強会がありました。この機会に、昨今のWeb API開発事情について知ってもらおうと思い、最近はチームでスキーマファースト開発をやってみているという話をしました。



スライドにも書いていますが、主に次のようなことを話しました。

  • スキーマファースト開発の概要
  • どのようなツールをどう使うか
  • サービス開発での実例

次のような質疑応答が(主にCTOとの間で)あった気がします。

  • スキーマ書くのがコストにならないか?
    • 他の部分で楽になるので、そこは歯を食いしばる。周辺ツールで楽にはなる
  • 最近、インターネットでGraphQLとかいう最先端技術を見たがどうか?
    • 向き不向きがありそう。参照系はGraphQLが有効そう
    • 実はGraphQLも徐々に使っているし、もっと広げていきたい

こういう考えかたがあるということを前提知識がない人も含めて説明するのはなかなか難しいですが、考えるなかで自分でもある程度整理がつけられたかなという感じです。また、このテーマはRubyKaigi 2017での@onkさんのAPI Development in 2017に影響を受けています。ありがとうございます。

おつかれさまでした。

RSpecのrequest specでCommitteeを使ってレスポンスJSONを自動的にバリデーションする

この記事の続きのようなものです。

blog.kymmt.com

やりたいこと

Rails + RSpecでWeb APIのrequest specを書くときに、Committee(とCommittee::Rails)の assert_schema_conform を使って、レスポンスのJSONがOpenAPIドキュメントで定義したレスポンスのJSON Schemaと一致するかどうか自動でチェックできるようにします。つまり、次のようにrequest specを書いたら自動でJSONのバリデーションが走ります。

describe 'User', type: :request do
  describe 'GET /users/:id' do
    it 'returns 200 OK' do
      get "/users/:id" # GETリクエスト発行後にJSONのバリデーションを自動で実行
    end
  end
end

前提

前述した記事の内容を実施しているものとします。

使うソフトウェアのバージョンは次のとおりです。

  • Rails 5.1.4
  • Committee 2.0.0
  • Committee::Rails 0.2.0

結論

先に結論を書いておくと、次のことをやればできます。

  • ActionDispatch::Integration::Session#process を実行したあとにCommitteeの assert_schema_conform を実行する

やりかたは後述の「HTTPリクエスト発行後に assert_schema_conform を実行する」を見てください。

request spec内でのHTTPリクエスト発行メソッドの正体を調べる

request specで get, post などのHTTPリクエストメソッドを発行したときに assert_schema_conform を実行したいので、まずはこれらのHTTPリクエスト発行メソッドの正体を調べます。

結論としては、これらのメソッドの実体は、RailsのAction Dispatch(以下AD, AD とします)における AD::Integration::Session という結合テスト時のHTTP通信セッション管理用クラスが持つメソッド #process です。このメソッドは次のようにHTTPメソッド、パス、パラメータなどHTTPリクエストを発行するのに必要なデータを受け取って、実際にリクエストを発行します。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L204
def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil)
  # ...
end

AD::Integration::Session#process はモジュール AD::integration::RequestHelpers で定義されている get, post などのヘルパーメソッドから呼び出されています。get の例を引用します。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L17-L19
module ActionDispatch
  module Integration #:nodoc:
    module RequestHelpers
      # Performs a GET request with the given parameters. See ActionDispatch::Integration::Session#process
      # for more details.
      def get(path, **args)
        process(:get, path, **args)
      end
      # ...

モジュール AD::integration::RequestHelpers はクラス AD::Integration::Sessioninclude されています。

モジュール AD::Integration::Runner は結合テストを実行するために AD::Integration::Session を使ってHTTP通信のセッションを開きます。そして、get, post などのメソッド呼び出しを AD::Integration::Session へ委譲するメソッドを動的に定義しています。AD::Integration::SessionAD::Integration::RequestHelperinclude しているので、委譲されてきたメソッド呼び出しを処理することができます。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L343-L354
module ActionDispatch
  module Integration
    # ...
    module Runner
      %w(get post patch put head delete cookies assigns follow_redirect!).each do |method|
        define_method(method) do |*args|
          # reset the html_document variable, except for cookies/assigns calls
          unless method == "cookies" || method == "assigns"
            @html_document = nil
          end

          # 注:integraion_session が Session のインスタンス
          integration_session.__send__(method, *args).tap do
            copy_session_variables!
          end
        end
      end
    # ...

rspec-railsでは、モジュール RSpec::Rails::RequestExampleGroup でモジュール AD::Integration::Runnerinclude しています。また、rspec-railsはrequest specのときにモジュール RSpec::Rails::RequestExampleGroupinclude します。これにより、request specでは get, post などが使えるようになっています。

HTTPリクエスト発行後に assert_schema_conform を実行する

ここまで把握したら、あとは Session#process を実行したあとに assert_schema_conform を差し込めばよさそうです。他にいいやりかたがあるかもしれませんが、今回は次のようにしました。

# spec/support/assert_schema_conform_available.rb
# CommitteeRailsOpenapi2 は前回記事参照
module AssertSchemaConformAvailable
  include CommitteeRailsOpenapi2

  def process(*args)
    super *args
    assert_schema_conform
  end
end

class ActionDispatch::Integration::Session
  prepend AssertSchemaConformAvailable
end

まず、#process を定義したモジュール AssertSchemaConformAvailable を作り、継承チェーンの上位に process があるとして、そのメソッドを呼んだあとに assert_schema_conform を単純に差し込んでいます。そして、このモジュールを AD::Integration::Sessionprepend することで、このモジュールが継承チェーンにおいて AD::Integration::Session の下位に入り、request specから getpost を呼んだときに AssertSchemaConformAvailable#process を呼べるようにしています。

あとは spec/rails_helper.rb でこのファイルを require しておけば、普通にrequest specを書くだけで、OpenAPIドキュメントに基づいて自動でレスポンスJSONをバリデーションできるようになります。

その他

  • OpenAPIドキュメントに書いていないパスがあれば AssertSchemaConformAvailable#process の中で除外しておく
  • OpenAPIドキュメントをファイル分割して書いて必要なときに結合する運用のときは、request spec実行前後でドキュメントを自動作成/削除すると便利そう

参考

この記事での試みはこちらに影響されております。

Web APIのレスポンスJSONをCommittee + OpenAPIでバリデーションして仕様と実装の乖離を防ぐ

APIドキュメントに書いたJSON Schemaと実際に実装したWeb APIのレスポンスJSONが一致するかバリデーションするためのCommitteeというgemがあります。また、このCommitteeをRailsプロジェクト中のテストから使うためのCommittee::Railsというgemがあります。

CommitteeはAPIドキュメントの形式としてJSON Hyper SchemaOpenAPI 2.0に対応しています。また、APIエンドポイントを叩いたときのレスポンスJSONがドキュメントで定義したJSON Schemaと一致したかを確認するアサーションメソッド assert_schema_conform を持っているので、このメソッドを使ってAPIドキュメントの実際の動作の乖離を未然に防ぐことができます。

今回はOpenAPI 2.0の形式で書いたAPIドキュメントを使って、Railsで作ったAPIのエンドポイントからのレスポンスをRspecのテストでバリデーションしてみます。

使用するライブラリのバージョン

ライブラリのバージョンは次のものとします。

  • Committee 2.0.0
  • Committee::Rails 0.2.0

例のAPI仕様

今回、次のようなAPIエンドポイントを持つ単純なアプリケーションを考えます。

  • GET /users/{userId}

このエンドポイントはステータスコード200で userId のIDを持つユーザを返します。ここで、ユーザは次のような属性を持つデータとします。

属性名 必須
id
email
name
age

すなわち、レスポンスのJSONは次のような形となります。

{
  "id": 1,
  "email": "foo@example.com",
  "name": "John Doe",
  "age": 25
}

OpenAPIドキュメントの記述

上述した仕様に基づいて、次のようなOpenAPI 2.0形式のドキュメントを書きます。

{
  "swagger": "2.0",
  "info": {
    "version": "1.0.0",
    "title": "Committee Rails Sample",
    "license": {
      "name": "MIT"
    }
  },
  "host": "example.com",
  "schemes": [
    "http"
  ],
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  "paths": {
    "/users/{userId}": {
      "get": {
        "summary": "get users",
        "operationId": "userShow",
        "tags": [
          "users"
        ],
        "parameters": [
          {
            "name": "userId",
            "in": "path",
            "description": "user ID",
            "required": true,
            "type": "string"
          }
        ],
        "responses": {
          "200": {
            "description": "A user",
            "schema": {
              "$ref": "#/definitions/User"
            }
          }
        }
      }
    }
  },
  "definitions": {
    "User": {
      "required": [
        "id",
        "email",
        "name"
      ],
      "properties": {
        "id": {
          "type": "integer"
        },
        "email": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "age": {
          "type": "integer"
        }
      },
      "additionalProperties": false
    }
  }
}

ここでは次の点に注目してもらえればOKです。

  • GET /usres/{userId} のレスポンスとして definitions 配下のデータ定義 User を使っている
  • データ定義 User では required で必須パラメータを指定しつつ、additionalPropertiesfalse を指定して記述したパラメータ以外が含まれることを禁じている

Committee::RailsでOpenAPIを使う準備

Committee::RailsでOpenAPIを使うために、Committee::Test::Methods#committee_schema というメソッドをオーバーライドします。このメソッドはAPIドキュメントに書いたJSON Schemaで実際のJSONをバリデーションするときに、そのAPIドキュメントを読み込むメソッドです。Committee::Railsでは Committee::Rails::Test::Methods#commitee_schema でJSON Hyper Schemaのドライバを使うようにあらかじめオーバーライドしていますが、今回はOpenAPI 2.0のドライバを使いたいので、自前でオーバーライドし直します。

# spec/support/committee_rails_openapi2.rb
module CommittteeRailsOpenapi2
  include Committee::Rails::Test::Methods

  def committee_schema
    @committee_schema ||=
      begin
        driver = Committee::Drivers::OpenAPI2.new
        schema_hash = JSON.parse(File.read(schema_path))
        driver.parse(schema_hash)
      end
  end

  def schema_path
    Rails.root.join('docs', 'swagger.json')
  end
end

ここでは、例としてRailsプロジェクトの docs/swagger.json に存在するOpenAPIドキュメントを読み込んでいます。

テストの記述

ここまで来ると、上述の committee_schema オーバーライドによって、APIエンドポイントが返すレスポンスがOpenAPIドキュメントに記述したJSON Schemaに一致するかどうかをRailsのテストで確認できるようになりました。テストはCommittee::RailsのREADMEに書かれているものとまったく同じで、RSpecを使うと次のように書けます。

# spec/requests/users_spec.rb
require 'rails_helper'

RSpec.describe 'Users', type: :request do
  describe 'GET /users/:id' do
    let!(:user) { create(:user) }

    it 'レスポンスがAPI定義と一致する' do
      get "/users/#{user.id}"
      assert_schema_conform
    end
  end
end

もしレスポンス用テンプレートの記述を間違えて必須属性 email を含めなかった場合、次のようなエラーが出ます。

  1) Users GET /users/:id レスポンスがAPI定義と一致する
     Failure/Error: assert_schema_conform

     Committee::InvalidResponse:
       Invalid response.

       #: failed schema #/properties//users/{userId}/properties/GET: "email" wasn't supplied.

また、もしレスポンス用テンプレートの記述を間違えてOpenAPIドキュメントで定義していない属性 phone を含めた場合、次のようなエラーが出ます。

  1) Users GET /users/:id レスポンスがAPI定義と一致する
     Failure/Error: assert_schema_conform

     Committee::InvalidResponse:
       Invalid response.

       #: failed schema #/properties//users/{userId}/properties/GET: "phone" is not a permitted key.

さらに、 属性 age は必須としない定義にしているので、レスポンスに age を含めなくてもエラーにはなりません。

なお、注意点として、ライブラリの実装上、正常系(ステータスコード200〜300番台)のレスポンスだけテストでき、異常系(ステータスコード400〜500番台)についてはテストできません*1

まとめ

  • Railsで作るWeb APIのレスポンスJSONがOpenAPIに定義したJSON Schemaと一致しているかをチェックするにはCommitteeとCommittee::Railsを使う
  • OpenAPI 2.0の形式のドキュメントを読み込むように必要なメソッドをオーバーライドしておく必要がある