RESTfulなWeb APIについて、その実装とOpenAPIドキュメントの仕様記述が乖離すると、APIのユーザは仕様と実際の挙動が違うことに戸惑うかもしれませんし、Swagger Codegenなどのツールを使ってOpenAPIドキュメントを有効に活用することができません。
この問題をRailsで構築しているWeb APIで防ぐために、ApivoreとRSpecを使って、実装とドキュメントに差異が生じたときに検出できるようにします。ApivoreはAPIの実装とOpenAPIドキュメントのあいだでエンドポイント仕様が一致することをRSpecでチェックするためのgemです。
gemのインストール
Gemfile
に次の行を追加して bundle install
してください。
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
に渡します。
describe 'API', type: :apivore do
let(:checker) { Apivore::SwaggerChecker.instance_for('/v1/swagger.json') }
end
ドキュメントに対する実装のチェック
swagger.json
に GET /products/{productId}
というエンドポイントの定義が次のとおり記述してあるとします。
{
"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"
}
}
}
}
}
このエンドポイントに対する実装とドキュメントの一致を見るテストは次にように書けます。
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
も指定できます。
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: :defined
を describe
に与えておきます。このオプションを指定すると、コードに書かれている順番にテストのexampleを実行します。
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
...