ApivoreでAPIの実装とOpenAPIドキュメントの乖離を防ぐ


RESTfulなWeb APIについて、その実装とOpenAPIドキュメントの仕様記述が乖離すると、APIのユーザは仕様と実際の挙動が違うことに戸惑うかもしれませんし、Swagger Codegenなどのツールを使ってOpenAPIドキュメントを有効に活用することができません。

この問題をRailsで構築しているWeb APIで防ぐために、[Apivore](https://github.com/westfieldlabs/apivore)とRSpecを使って、実装とドキュメントに差異が生じたときに検出できるようにします。ApivoreはAPIの実装とOpenAPIドキュメントのあいだでエンドポイント仕様が一致することをRSpecでチェックするためのgemです。

# gemのインストール

`Gemfile` に次の行を追加して `bundle install` してください。

```ruby:Gemfile
gem 'apivore'
```

本記事ではApivore 1.6.2を使います。

# スペックの記述

## 準備

### `swagger.json` の配信

テストで使うために、OpenAPIに則って書いた `swagger.json` をローカル環境でAPIから取得できるようにしておきます（`public` ディレクトリ配下にJSONを置く、swagger-blocksを使って動的に作成する、など）。つまり、次のように動作する状態にします。

```
$ curl https://localhost:3000/v1/swagger.json | jq .
{
  "swagger": "2.0",
  "info": {
    "description": "This is a sample server Petstore server.  You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/).  For this sample, you can use the api key `special-key` to test the authorization filters.",
    "version": "1.0.0",
    "title": "Swagger Petstore",
    "termsOfService": "http://swagger.io/terms/",
    "contact": {
      "email": "apiteam@swagger.io"
    },
    "license": {
      "name": "Apache 2.0",
      "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
    }
  },
  ...
}
```

### `Apivore::SwaggerChecker` の生成

ApivoreをRSpecのテストで使うときは、オプションとして `type: :apivore` を渡します。

`Apivore::SwaggerChecker` をファクトリメソッド `.instance_for` で生成します。このとき、`swagger.json` を配信しているAPIのパスを引数として `.instance_for` に渡します。

```ruby:spec/requests/api_spec.rb
describe 'API', type: :apivore do
  let(:checker) { Apivore::SwaggerChecker.instance_for('/v1/swagger.json') }
end
```

## ドキュメントに対する実装のチェック

`swagger.json` に `GET /products/{productId}` というエンドポイントの定義が次のとおり記述してあるとします。

```json5:swagger.json
{
  "paths": {
    "/products/{productId}": {
      "get": {
        "produces": [
          "application/json"
        ],
        "parameters": [
          {
            "name": "productId",
            "in": "path",
            "required": true,
            "type": "integer",
            "format": "int64"
          }
        ],
        "responses": {
          "200": {
            "schema": {
              "$ref": "#/definitions/Product"
            }
          }
        }
      }
    }
  },
  "definitions": {
    "Product": {
      "type": "object",
      "required": [
        "name"
      ],
      "properties": {
        "id": {
          "type": "integer",
          "format": "int64"
        },
        "name": {
          "type": "string"
        }
      }
    }
  }
}
```

このエンドポイントに対する実装とドキュメントの一致を見るテストは次にように書けます。

```ruby:spec/requests/api_spec.rb
describe 'API', type: :apivore do
  let(:checker) { Apivore::SwaggerChecker.instance_for('/v1/swagger.json') }
  let(:product) { FactoryGirl.create(:product) } # factory_girlのフィクスチャが定義済みとする

  it { expect(checker).to validate(:get, '/products/{productId}', 200, { productId: product.id }) }
end
```

このテストは、`GET /products/{productId}` に対してリクエストを送り、次の項目をチェックしています。

- `GET /products/{productId}` のエンドポイントが実際に存在すること
- レスポンスのステータスコードが実装とドキュメントで一致していること
- 実際のレスポンスJSONの形式がドキュメントに書かれたJSONスキーマと一致していること

これらのチェックの少なくとも一つがNGであればテストは失敗するので、APIの実装とドキュメントの乖離が防げます。

## パラメータの指定

`validate` に対して、`_query_string` をキーとするHashとしてクエリパラーメタを渡せます。また、同様にリクエストボディ `_data`, リクエストヘッダ `_headers` も指定できます。

```ruby:spec/requests/api_spec.rb
describe 'API', tpye: :apivore do
  let(:checker) { Apivore::SwaggerChecker.instance_for('/public/v1/swagger.json') }
  let(:params) {
    {
      _query_string: 'name=cup',
      _data: 'request body.',
      _headers: { 'Authentication': 'Basic XXXX' }
    }
  }
  
  it { expect(checker).to validate(:get, '/products.json', 200, params) }
end
```

なお、Apivoreは `validate` 実行時にクエリパラメータの形式がドキュメントと一致しているかについてはチェックしてくれません。

次のようにパラメータを作っておくと、あとで `path_params`, `headers`, `query_params`, `data_params` を上書きして値を変更しやすくなります。

```ruby
let(:path_params) {{}}
let(:headers) {{}}
let(:query_params) {{}}
let(:data_params) {{}}
let(:params) {
  path_params.merge(
    '_headers' => headers,
    '_query_string' => query_params.to_query,
    '_data' => data_params
  )
}
```

## 全エンドポイントをテストしたことのチェック

Apivoreには、すべてのエンドポイントを `validate` でチェックしたかどうかをチェックする `validate_all_paths` というヘルパーも存在します。`validate_all_paths` はすべてのエンドポイントについて `validate` を実行したあとに実行したいので、テストがランダムに実行される場合を考えて、RSpecのオプション `order: :defined` を `describe` に与えておきます。このオプションを指定すると、コードに書かれている順番にテストのexampleを実行します。

```ruby:spec/requests/api_spec.rb
RSpec.describe 'API', type: :apivore, order: :defined do
  let(:checker) { Apivore::SwaggerChecker.instance_for('/v1/swagger.json') }
  let(:path_params) { {} }
  let(:headers) { {} }
  let(:query_params) { {} }
  let(:data_params) { {} }
  let(:params) {
    path_params.merge(
      '_headers' => headers,
      '_query_string' => query_params.to_query,
      '_data' => data_params
    )
  }

  context 'エンドポイントのテスト', order: :random do
    # 各エンドポイントのテストはランダム実行でもOK
    # shared_context に切り出しておくと見やすい
    include_context 'Product API'
  end

  it '全エンドポイントをテスト済み' do
    # このexampleは最後に実行したいので各エンドポイントのテストの後に書く
    expect(checker).to validate_all_paths
  end
end
```

テストしていないエンドポイントが検出されたときは次のようなエラーが表示されます。

```
1) 全エンドポイントをテスト済み
     Failure/Error: expect(checker).to validate_all_paths

       get /products/{productId} is untested for response code 404
       post /products is untested for response code 201
   ...   
```

# 参考

- [Automating Empathy: Test Your Documentation With Swagger and Apivore - AMC: Aspiring Master of Code](http://amcaplan.ninja/blog/2016/12/27/automating-empathy-test-your-documentation-with-swagger-and-apivore/)