Posted at

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

More than 1 year has passed since last update.

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

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


gemのインストール

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


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 に渡します。


spec/requests/api_spec.rb

describe 'API', type: :apivore do

let(:checker) { Apivore::SwaggerChecker.instance_for('/v1/swagger.json') }
end


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

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


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"
}
}
}
}
}

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


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 も指定できます。


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 を上書きして値を変更しやすくなります。

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: :defineddescribe に与えておきます。このオプションを指定すると、コードに書かれている順番にテストのexampleを実行します。


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
...


参考