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の形式のドキュメントを読み込むように必要なメソッドをオーバーライドしておく必要がある

Yokohama.rb Monthly Meetup #84 に参加した

2017-09-09(土)のYokohama.rb Monthly Meetup #84参加のメモです。

yokohamarb.doorkeeper.jp

Rubyレシピブック

今回から最終章のひとつ手前、第10章の「Webプログラミング」に入りました。次のレシピを読みました。

  • 261: RSSを扱う
  • 262: Rackを使う
  • 263: フォームから入力された値を取り出す
  • 264: クエリ文字列を取り出す

標準添付ライブラリのひとつであるrssを触ったり、クライアントからのリクエストを受けてレスポンスを返すコードを標準添付ライブラリcgiと言わずと知れたRackそれぞれを使って書いたりしていました。

その他

仮想通貨(ビットコイン/モナコイン)で決済できるECモールをRails+AWSで開発/運用されている方から、サービスの紹介と開発について発表がありました。最近、周囲でビットコインを触っている人が増えたので、仮想通貨自体がだいぶ気になってきました。

また、るびま編集の@miyohideさんがいるということで、先日話題になった@kbaba1001さんのHanamiの記事をみんなで読んでいました。「まとめ」に書かれている内容が本当に大事。



来月10月のYokohama.rbはお休みです。

次回はおそらく11月第2週の土曜日になるかと(勝手に)思っています。レシピブックの最後のほうを読みたい人やRubyについて雑にお話ししたい人はぜひ。

OpenAPI v3.0.0のCallback Objectについて調べた

2017年7月末リリースのOpenAPI v3.0.0に入ったCallback Objectという仕様がパッと見だとどう使うかわかりにくかったので、調べてみました。

Callback Objectの仕様は次のリンク先のとおりです。

OpenAPI-Specification/3.0.0.md at master · OAI/OpenAPI-Specification

Callack Objectについて簡単に述べると、「OpenAPIドキュメントで記述しているエンドポイントに対してリクエストしたあと、こちらが指定したエンドポイントに対してリクエスト先がwebhookなどでコールバックするような場合についての仕様が記述できるもの」、です。

まだわかりにくいので、OpenAPI公式のリポジトリに置いてある次のサンプルを見てみます。

まず、このYAML中のキー post 配下を見てみると次のようになっています(抜粋)。

post:
  description: subscribes a client to receive out-of-band data
  parameters:
    - name: callbackUrl
      in: query
      required: true
      description: # ...
      schema:
        type: string
        format: uri
        example: https://tonys-server.com
  responses:
    '201':
      description: subscription successfully created
      content:
        application/json:
          schema:
            description: subscription information
            required:
              - subscriptionId
            properties:
              subscriptionId:
                description: this unique identifier allows management of the subscription
                type: string
                example: 2531329f-fb09-4ef7-887e-84e648214436

ここでは、

  • /streams というエンドポイントに対して callbackUrl というパラメータとともにPOSTリクエストを投げることができる
  • リクエストが成功すると subscription(購読)リソースが作成される

というエンドポイント仕様が記述されていることがわかります。

parameters, responses のさらに下の callbacks 配下にある記述が、今回着目するCallbacks Objectです(抜粋)。

callbacks:
  onData:
    '{$request.query.callbackUrl}/data':
      post:
        requestBody:
          description: subscription payload
          content:
            application/json:
              schema:
                properties:
                  timestamp:
                    type: string
                    format: date-time
                  userData:
                    type: string
        responses:
          '202':
            description: # ...
          '204':
            description: # ...

これは、このAPIを提供している側(仕様書内ではAPI providerと呼ばれています)はクライアント側が指定した '{$request.query.callbackUrl}/data' というURLに対して requestBody の内容でPOSTリクエストするようになるという記述です。

ここで、$request.query.callbackUrl のようなデータの指定方法は仕様書の Key Expression の項に書いてあるURL, HtTPメソッド、HTTPリクエストのデータ、HTTPレスポンスの Location ヘッダが取得できる「実行時式」(runtime expression) です。

responses には返すべきステータスコードなどが記述できます。ここでいうエンドポイント '{$request.query.callbackUrl}/data' を持つホストは responses に書いた記述を満たすようにレスポンスを返す必要があります。

以上のようにして、Callback Objectを使うことで、あるエンドポイントに付随したAPI provider側からのコールバックについての仕様も記述できるようになります。