committeeを利用してOpenAPI Specのスキーマ定義とAPIの挙動の差分を検知する

バックエンド

REST APIを開発するにあたり、OpenAPI Specification(OpenAPI 3.0以前でいうところのSwagger Specification)をAPIの仕様書として利用している方は多いのではないでしょうか。

しかし、OpenAPI Specificationを仕様書として利用する場合、きちんとメンテナンスを続けなければ実際のAPIの挙動と仕様書の内容に違いが生まれてきてしまいます。

committeeを利用することで、OpenAPI Specificationと実際のAPIの挙動が一致しているかテストコードで検証できます。

今回はcommitteeの導入手順と、実際にテストコードでcommitteeを利用する方法について紹介します。

committee-rails0.5.1committee4.2.1を利用します。

今回の検証環境とサンプルAPIについて

今回は「API開発 + Swagger UIを利用したAPI検証」な環境をDockerで構築するで紹介した、以下のようなDocker環境で検証を行ます。

「API開発 + Swagger UIを利用したAPI検証」な環境をDockerで構築する

検証用のAPIは、APIモードで作成したRailsアプリケーション上でscaffoldを実行することで作成しました。
今回はEventというモデルのREST APIを作成しました。

$ rails g scaffold event title:string

sacaffoldで作成されたエンドポイント一覧は以下の通りです。

  • GET /events
  • POST /events
  • GET /events/{id}
  • PATCH /events/{id}
  • DELETE /events/{id}

OpenAPI Specificationは以下の通りです。

api.yaml

openapi: 3.0.2
info:
  title: サンプルAPI
  version: 1.0.0
servers:
  - url: http://localhost:3000
tags:
- name: イベント
paths:
  /events:
    get:
      tags:
        - イベント
      description: イベント一覧取得
      responses:
        200:
          description: 成功
          content:
            application/json:
              schema:
                type: array
                description: イベントの配列
                items:
                  $ref: "#/components/schemas/Event"
    post:
      tags:
        - イベント
      description: イベント登録
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  example: サンプルイベント
      responses:
        201:
          description: 作成
          content:
            application/json:
              schema:
                type: object
                $ref: "#/components/schemas/Event"

  /events/{event_id}:
    get:
      tags:
        - イベント
      description: イベント詳細
      parameters:
        - name: event_id
          in: path
          description: イベントID
          required: true
          schema:
            type: integer
            format: int64
            example: 1
      responses:
        200:
          description: 成功
          content:
            application/json:
              schema:
                type: object
                $ref: "#/components/schemas/Event"
        404:
          description: event not found
    patch:
      tags:
        - イベント
      description: イベント更新
      parameters:
        - name: event_id
          in: path
          description: id
          required: true
          schema:
            type: integer
            format: int64
          example: 1
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  example: サンプルイベント
      responses:
        200:
          description: 成功
          content:
            application/json:
              schema:
                type: object
                $ref: "#/components/schemas/Event"
    delete:
      tags:
        - イベント
      description: イベント削除
      parameters:
        - name: event_id
          in: path
          description: id
          required: true
          schema:
            type: integer
            format: int64
            example: 1
      responses:
        204:
          description: No Content
components:
  schemas:
    Event:
      type: object
      properties:
        id:
          description: ID
          type: integer
          format: int64
          example: 1
        title:
          description: タイトル
          type: string
          example: サンプルイベント
        created_at:
          description: 作成日
          type: string
          format: date-time
          example: 2020-04-01 10:00
        updated_at:
          description: 更新日
          type: string
          format: date-time
          example: 2020-04-01 10:00

テストコードは以下の通りです。
今回はテスティングフレームワークにRSpecを利用しています。

REST APIの挙動についてはテストで確認できているが、OpenAPI Specificationを利用したスキーマ検証は行えていない状態です。

spec/requests/events_spec.rb

require 'rails_helper'

describe 'Events', type: :request do
  let(:json) { JSON.parse(response.body) }
  let(:request_header) { { "Content-Type" => "application/json" } }

  describe 'GET /events' do
    let!(:event) { create :event }
    before do
      get '/events'
    end
    it '取得できる' do
      expect(response).to have_http_status 200
      expect(json.length).to eq(1)
    end
  end

  describe 'GET /events/:id' do
    let!(:event) { create :event }
    before do
      get "/events/#{event.id}"
    end
    it '取得できる' do
      expect(response).to have_http_status 200
    end
  end

  describe 'POST /events' do
    it '登録できること' do
      expect {
        post "/events",
        params: { event: { title: 'サンプルイベント' } }.to_json,
        headers: request_header
      }.to change { Event.count }.from(0).to(1)
      expect(response).to have_http_status 201
    end
  end

  describe 'PATCH/PUT /events/:id' do
    let!(:event) { create :event }
    before do
      patch "/events/#{event.id}",
        params: { event: { title: 'サンプルイベント更新' } }.to_json,
        headers: request_header
    end
    it '更新できること' do
      expect(response).to have_http_status 200
    end
  end

  describe 'DELEET /events/:id' do
    let!(:event) { create :event }
    it '削除できること' do
      expect {
        delete "/events/#{event.id}"
      }.to change { Event.count }.from(1).to(0)
      expect(response).to have_http_status 204
      expect(response.body).to be_empty
    end
  end
end

なお、REST APIのRSpecを実行する環境の構築方法については今回省略しますので、詳細についてはRSpecでAPIのレスポンスボディのプロパティを検証する方法を参照してください。

RSpecでAPIのレスポンスボディのプロパティを検証する方法

committeeの導入方法

今回はcommittee-railsを利用してcommitteeを導入します。

Gemfile

group :test do
  gem 'committee-rails'
end
$ bundle install

spec/rails_helper.rbにcommitteeの設定を追記すればOKです。
今回はnginx/html/swagger-ui/api.yamlに作成したOpenAPI Specificationを読み込むようにしています。

spec/rails_helper.rb

RSpec.configure do |config|
  config.add_setting :committee_options
  config.committee_options = { schema_path: Rails.root.join('nginx', 'html', 'swagger-ui', 'api.yaml').to_s }
end

committeeにはスキーマを検証するメソッドが3つ用意されています。

  • assert_schema_conform
  • assert_request_schema_confirm
  • assert_response_schema_confirm

リクエストの検証をする場合はassert_request_schema_confirm、レスポンスの検証をする場合はassert_response_schema_confirmを利用します。

assert_schema_conformはcommitteeのバージョンによって挙動が異なります。
今まではレスポンスのみを検証するメソッドとして提供されていましたが、これからはリクエストとレスポンスを両方検証するようになる予定です。(2020年11月現在)

assert_schema_conformでリクエストとレスポンスの検証を両方行うようにする(つまり今までの挙動ではなくする)ためにはold_assert_behavior: falseを追加します。

spec/rails_helper.rb

RSpec.configure do |config|
  config.add_setting :committee_options
  config.committee_options = { schema_path: Rails.root.join('nginx', 'html', 'swagger-ui', 'api.yaml').to_s, old_assert_behavior: false }
end

もし、old_assert_behavior: falseを設定せずにassert_schema_conformを利用すると以下のようなwarningメッセージがコンソールに表示されます。

[DEPRECATION] now assert_schema_conform check response schema only. but we will change check request and response in future major version. so if you want to conform response only, please use assert_response_schema_confirm, or you can suppress this message and keep old behavior by setting old_assert_behavior=true.

設定完了後、RSpecのファイル内でinclude Committee::Rails::Test::Methodsを宣言すればOKです。
もしくは、以下のように設定ファイルでincludeの宣言をすることで、各RSpecのファイルでinclude Committee::Rails::Test::Methodsを記述しなくて済むようになります。

spec/rails_helper.rb

RSpec.configure do |config|
  config.include Committee::Rails::Test::Methods
  config.add_setting :committee_options
  config.committee_options = { schema_path: Rails.root.join('nginx', 'html', 'swagger-ui', 'api.yaml').to_s, old_assert_behavior: false }
end

committeeの使い方

スキーマの検証を行たいテストに対してassert_schema_conformを追加すればOKです。
今回はold_assert_behavior: falseの設定をしているので、assert_schema_conformでリクエストもレスポンスも検証されます。

committeeによるスキーマの検証を追加したRSpecは以下の通りです。
冒頭で紹介した差分はassert_schema_conformがあるかないかだけです。

spec/requests/events_spec.rb

require 'rails_helper'

describe 'Events', type: :request do
  let(:json) { JSON.parse(response.body) }
  let(:request_header) { { "Content-Type" => "application/json" } }

  describe 'GET /events' do
    let!(:event) { create :event }
    before do
      get '/events'
    end
    it '取得できる' do
      expect(response).to have_http_status 200
      expect(json.length).to eq(1)
      assert_schema_conform
    end
  end

  describe 'GET /events/:id' do
    let!(:event) { create :event }
    before do
      get "/events/#{event.id}"
    end
    it '取得できる' do
      expect(response).to have_http_status 200
      assert_schema_conform
    end
  end

  describe 'POST /events' do
    it '登録できること' do
      expect {
        post "/events",
        params: { event: { title: 'サンプルイベント' } }.to_json,
        headers: request_header
      }.to change { Event.count }.from(0).to(1)
      expect(response).to have_http_status 201
      assert_schema_conform
    end
  end

  describe 'PATCH/PUT /events/:id' do
    let!(:event) { create :event }
    before do
      patch "/events/#{event.id}",
        params: { event: { title: 'サンプルイベント更新' } }.to_json,
        headers: request_header
    end
    it '更新できること' do
      expect(response).to have_http_status 200
      assert_schema_conform
    end
  end

  describe 'DELEET /events/:id' do
    let!(:event) { create :event }
    it '削除できること' do
      expect {
        delete "/events/#{event.id}"
      }.to change { Event.count }.from(1).to(0)
      expect(response).to have_http_status 204
      expect(response.body).to be_empty
      assert_schema_conform
    end
  end
end

committeeで検出できる差分の例

OpenAPI Specificationで定義されたAPIの仕様と、実際のAPIの挙動に差分がなければテストはパスします。
以下ではcommitteeで検出できる差分の例について紹介します。

プロパティの型が違う場合

OpenAPI Specificationで定義しているレスポンスボディのidの型をわざとintegerからstringに変更してみます。

api.yaml

id:
  description: ID
- type: integer
- format: int64
+ type: string
  example: 1

実際のAPIではidはintegerで返されるので、テストを実行すると以下のようなエラーになります。

Committee::InvalidResponse:
  #/components/schemas/Event/properties/id expected string, but received Integer: 194

プロパティの値がnullの場合

たとえば、Eventのtitleがnullで返ってきた場合は以下のようなエラーになります。

Committee::InvalidResponse:
  #/components/schemas/Event/properties/title does not allow null values

nullを許可する場合はOpenAPI Specificationでnullable: trueをセットします。

api.yaml

title:
  description: タイトル
  type: string
  example: サンプルイベント
+ nullable: true

committeeでより厳密にAPIを定義するための方法

OpenAPI Specificationのオプションを活用することで、APIの挙動をより厳密にチェックできます。

未定義のプロパティを検知する方法

additionalProperties: falseを追加することでスキーマで定義されたプロパティしか持てなくなります。

api.yaml

schemas:
  Event:
    type: object
    properties:
      id:
        description: ID
        type: integer
        format: int64
        example: 1
      title:
        description: タイトル
        type: string
        example: サンプルイベント
      created_at:
        description: 作成日
        type: string
        format: date-time
        example: 2020-04-01 10:00
      updated_at:
        description: 更新日
        type: string
        format: date-time
        example: 2020-04-01 10:00
+   additionalProperties: false

これにより、実際のAPIで利用・取得されるプロパティとOpenAPI Specificationで定義したプロパティが一致しているか厳密にチェックできます。

たとえば、プロパティ名がtitleなのをOpenAPI Specificationでttileとしたり未定義だったりした場合、以下のようなエラーになります。

Committee::InvalidResponse:
  #/components/schemas/Event does not define properties: title

必須プロパティを設定する方法

requiredを利用すること必須プロパティを定義できます。

OpenAPI Specificationでrequiredとして扱われているプロパティが実際のAPIに含まれていない場合、どういったエラーになるのか試してみます。

以下ではわざとdetailという実際には未定義のプロパティを追記し、必須プロパティとしています。

api.yaml

schemas:
  Event:
    type: object
    properties:
      id:
        description: ID
        type: integer
        format: int64
        example: 1
      (..略..)
+     detail:
+       description: 詳細
+       type: string
+       example: サンプルイベントの詳細情報
+   required:
+     - detail

実際のAPIにはdetailプロパティは含まれないため、以下のようなエラーが発生します。

Committee::InvalidResponse:
  #/components/schemas/Event missing required parameters: detail

まとめ

  • committeeを利用することでOpenAPI Specificationと実際のAPIの挙動の差分を検証できる
  • スキーマの検証はassert_schema_conformで行う
  • Railsの場合はcommitt-railsを利用するとcommitteeの導入がラク

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!