OpenAPIスキーマで enum
を使うと、その項目が取り得る値を指定できます。
Enums - DataModels (Schemas) - OpenAPI Guide - swagger
例えば、以下のOpenAPIスキーマのようにレスポンスの型が定義されているAPIの場合、HTTPレスポンスボディの shop
が取り得る値は スーパー
と 産直所
になります。
responses: '200': description: successful operation content: application/json: schema: type: object properties: shop: type: string enum: - スーパー - 産直所
そんな中、「OpenAPIスキーマは提供されているものの、何らかの理由で enum
の定義にない値が返ってくるAPI」があった時に、Ruby版のopenapi-generatorで生成したAPIクライアントがエラーになってしまうことに気づいたため、回避策をメモします。
ちなみに、「 enum
の定義にない値が返ってくる場合に、OpenAPIクライアントがエラーとなってしまうこと」は、本来想定していないことだと考えています。そのため、Ruby版のOpenAPI generatorの不具合というよりは仕様な気がします。
目次
- 環境
- 環境構築
- 検証:サーバアプリにて、 enum で定義されていない値を返すように修正
- 回避策:APIクライアントでAPIを呼ぶ時 debug_return_type に String を渡す
- 余談:committee-rails を使ってOpenAPIスキーマとレスポンスのズレに気づく
- ソースコード
環境
今回はAPIを提供する側(サーバアプリ)とAPIを利用する側(クライアントアプリ) をRailsで作成してみます。
サーバアプリ
クライアントアプリ
OpenAPIスキーマ
サーバアプリとクライアントアプリ間で共有するOpenAPIスキーマは以下です。
openapi: 3.0.0 info: title: Rails with OpenAPI version: 0.0.1 servers: - url: http://localhost:3001 paths: /api/mismatch/fruits: get: summary: OpenAPIスキーマとレスポンスが乖離しているAPI responses: '200': description: successful operation content: application/json: schema: type: object properties: tags: type: array items: type: string enum: - 市内 - 県内 shop: type: string enum: - スーパー - 産直所 fruits: type: array items: type: object properties: name: type: string enum: - りんご - みかん
環境構築
まず、サーバアプリとクライアントアプリを作成します。
サーバアプリの構築
rails new
--api
をつけて rails new します。
--skip-bundle
は好みで付けています。後で bundle install
を忘れずに行います。
% bundle exec rails new rails_7_0_openapi_server_app --api --skip-bundle
コントローラの作成
今回は app/controllers/api/mismatch/fruits_controller.rb
として作成します。
この時点ではOpenAPIスキーマの enum にある値だけ返すようにします。
class Api::Mismatch::FruitsController < ApplicationController def index render json: { tags: ['市内'], shop: '産直所', fruits: [ { name: 'りんご' }, { name: 'みかん' }, ] } end end
routesの作成
コントローラに合わせてルーティングを作成します。
Rails.application.routes.draw do namespace :api do namespace :mismatch do resources :fruits, only: [:index] end end end
動作確認
サーバアプリを起動後、curlでレスポンスが返ってくればOKです。
% curl 'http://localhost:3001/api/mismatch/fruits' {"tags":["市内"],"shop":"産直所","fruits":[{"name":"りんご"},{"name":"みかん"}]}
クライアントアプリの構築
rails new
こちらも --api
で rails new します。
% bundle exec rails new rails_7_0_openapi_client_app --api --skip-bundle
OpenAPIスキーマからOpenAPIクライアントを生成
続いて、クライアントアプリのディレクトリ内にOpenAPIスキーマ (openapi.yaml
) を置き、 OpenAPI generator で Open API クライアントを生成します。
generatorはいくつかの方法で使えますが、今回は OpenAPI generator 公式で提供されているDockerイメージを使います。
1.6 - Docker | OpenAPITools/openapi-generator: OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)
なお、生成したOpenAPIクライアントは、クライアントアプリのルート直下に作成する openapi
ディレクトリ内に置きます。
また、OpenAPIクライアントは gem 形式で作成されます。そのため、Gemfile に記載して bundle install
してクライアントアプリに導入します。
docker-compose.yaml によるOpenAPIクライアントの生成
公式ドキュメントのように docker run
での実行も考えましたが、コマンドを手打ちするのは大変です。
そこで、以下を参考にして、docker-compose で command を実行することで手間を減らすようにします。
OpenAPI + Docker + Nuxt + TypeScriptで快適な開発環境を - inokawablog
まず、以下のような docker-compose.yaml
を用意します。
version: '3' services: openapi_ruby: image: openapitools/openapi-generator-cli:v6.0.0 volumes: - ./:/local command: generate -i local/openapi.yaml -g ruby -o local/openapi
Dockerの準備ができたので、OpenAPIクライアントを生成します。
なお、実行後にコンテナが残り続けても困るので、 --rm
を追加します。
docker-compose run | Docker Documentation
% docker compose run --rm openapi_ruby [main] INFO o.o.codegen.DefaultGenerator - Generating with dryRun=false ... [main] INFO o.o.codegen.TemplateManager - writing file /local/openapi/.openapi-generator/FILES ################################################################################ # Thanks for using OpenAPI Generator. # # Please consider donation to help us maintain this project 🙏 # # https://opencollective.com/openapi_generator/donate # ################################################################################
OpenAPIクライアントのbundle install
Gemfileに追記します。
# OpenAPI generatorで生成したRubyクライアント gem "openapi_client", path: 'openapi'
インストールします。
% bundle install
コントローラの作成
OpenAPIクライアントができたので、コントローラ (app/controllers/api/mismatch/error/fruits_controller.rb
)にて利用してみます。
class Api::Mismatch::Error::FruitsController < ApplicationController def index api_instance = OpenapiClient::DefaultApi.new begin #OpenAPIスキーマとレスポンスが乖離しているAPI result = api_instance.api_mismatch_fruits_get p result rescue OpenapiClient::ApiError => e puts "Exception when calling DefaultApi->api_mismatch_fruits_get: #{e}" end end end
routesの作成
Rails.application.routes.draw do namespace :api do namespace :mismatch do namespace :error do resources :fruits, only: [:index] end end end end
動作確認
クライアントアプリ/サーバアプリの両方とも起動した状態で、curlを使って動作確認します。
% curl 'http://localhost:3002/api/mismatch/error/fruits'
ログに以下が出力されています。クライアントアプリ経由でサーバアプリのデータが取得できているようです。
Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:02:24 +0900 Processing by Api::Mismatch::Error::FruitsController#index as */* ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.198827 #<OpenapiClient::InlineResponse200:0x00000001148b2aa8 @tags=["市内"], @shop="産直所", @fruits=[#<OpenapiClient::InlineResponse200Fruits:0x00000001148b2468 @name="りんご">, #<OpenapiClient::InlineResponse200Fruits:0x00000001148b1f40 @name="みかん">]> Completed 204 No Content in 209ms (Allocations: 1113)
検証:サーバアプリにて、 enum で定義されていない値を返すように修正
ここからが本題です。
作成したアプリを修正していきます。
サーバアプリのコントローラを修正
OpenAPIスキーマのenumで定義されていない値を返すよう、サーバアプリのコントローラを修正します。
class Api::Mismatch::FruitsController < ApplicationController def index render json: { tags: ['近所'], shop: 'コンビニ', fruits: [ { name: 'りんご' }, { name: 'みかん' }, { name: 'ぶどう' } ] } end end
動作確認
curlで動作確認したところ、エラーが返ってきました。
% curl 'http://localhost:3002/api/mismatch/error/fruits' {"status":500,"error":"Internal Server Error" ...
クライアントアプリのログを見ると、 shop
に定義されていない値が含まれているため、エラーになっていたようです。
Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:07:51 +0900 Processing by Api::Mismatch::Error::FruitsController#index as */* ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.176037 Completed 500 Internal Server Error in 188ms (Allocations: 1095) ArgumentError (invalid value for "shop", must be one of ["スーパー", "産直所"].):
そこで、 shop
は定義されている値を返すよう、サーバアプリのコントローラを
class Api::Mismatch::FruitsController < ApplicationController def index render json: { # OpenAPIスキーマのenumで定義されていない値の場合 tags: ['近所'], shop: '産直所', # 変更後 fruits: [ { name: 'りんご' }, { name: 'みかん' }, { name: 'ぶどう' } ], } end end
として再度curlを実行したところ、別のエラーになりました。
Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:17:24 +0900 Processing by Api::Mismatch::Error::FruitsController#index as */* ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.166197 Completed 500 Internal Server Error in 185ms (Allocations: 1160) ArgumentError (invalid value for "name", must be one of ["りんご", "みかん"].):
さらに、再度コントローラを
class Api::Mismatch::FruitsController < ApplicationController def index render json: { # OpenAPIスキーマのenumで定義されていない値の場合 tags: ['近所'], shop: '産直所', fruits: [ # 変更: nameがぶどうな要素を削除 { name: 'りんご' }, { name: 'みかん' }, ], } end end
として curl を実行したところ、 tags
の値は enum に含まれないままなのに、エラーが出なくなりました。
Started GET "/api/mismatch/error/fruits" for 127.0.0.1 at 2022-05-28 00:18:31 +0900 Processing by Api::Mismatch::Error::FruitsController#index as */* ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.146364 #<OpenapiClient::InlineResponse200:0x0000000113f66e90 @tags=["近所"], @shop="産直所", @fruits=[#<OpenapiClient::InlineResponse200Fruits:0x0000000113f66490 @name="りんご">, #<OpenapiClient::InlineResponse200Fruits:0x0000000113f65f40 @name="みかん">]> Completed 204 No Content in 147ms (Allocations: 1100)
エラーになる/ならないの違いは何だろうと思い、OpenAPIスキーマを見直します。
schema: type: object properties: tags: type: array items: type: string enum: - 市内 - 県内 shop: type: string enum: - スーパー - 産直所 fruits: type: array items: type: object properties: name: type: string enum: - りんご - みかん
より、
type: object
なオブジェクトのproperties
の項目にてenum
を指定した場合は、エラーになる- 上記の例では
shop
やfruits
- 上記の例では
type: array
な要素がtype: object
でない場合、要素の定義でenum
を指定してもエラーにならない- 上記の例では
tags
- 上記の例では
なのかもしれないと思いました。
問題点
そもそも、OpenAPIスキーマの enum
で定義されていない値が返ってくるのでは困ってしまいます。
そのため、手元で管理しているAPIであれば、OpenAPIスキーマとAPIのレスポンスが一致するよう修正します。
一方、手元で管理していないAPIで発生した場合は、修正されるまでに時間がかかってしまうかもしれません。
そこで、OpenAPIスキーマの enum で定義されていない値が返ってきたとしてもOpenAPIクライアントではエラーにならないよう、回避策を調べてみることにしました。
回避策:APIクライアントでAPIを呼ぶ時 debug_return_type に String を渡す
回避策の調査
まず、APIクライアントのどこでエラーになっているかを調べたところ、 convert_to_type()
でのデータ変換時にエラーとなっていました。
https://github.com/OpenAPITools/openapi-generator/blob/v6.0.0/modules/openapi-generator/src/main/resources/ruby-client/api_client.mustache#L162
次に、APIクライアントのメソッドを呼んでからエラーになるまでの流れを追いかけたところ、以下のようでした。
- OpenAPIクライアントのメソッド呼び出す時に
debug_return_type
キーに値を渡すことで、return_type
が決まる - 決まった
return_type
をオプションの一部にして、APIを呼ぶ - オプションに
return_type
を含めたまま、deserialize()
メソッドを呼ぶ deserialize()
の中で、convert_type()
を呼び出す
この流れの中で convert_type()
の前にいい感じでデータを返す方法があるか探したところ、「return_type
が String
だったら、レスポンスボディを直接返す」という処理を見つけました。
https://github.com/OpenAPITools/openapi-generator/blob/v6.0.0/modules/openapi-generator/src/main/resources/ruby-client/api_client.mustache#L105
これより、OpenAPIクライアントのメソッドを呼ぶ時際、 debug_return_type
に String
を設定することで、Stringなレスポンスボディをそのまま取得できそうでした。
ただ、Stringのままでは扱いづらいので、Stringなレスポンスボディを JSON.parse()
すれば良さそうです。
回避策の実装
まずは新しくコントローラ(app/controllers/api/mismatch/workaround/fruits_controller.rb
)を作り、OpenAPIクライアントのメソッドを呼ぶ時に debug_return_type
に String
を渡すようにします。
class Api::Mismatch::Workaround::FruitsController < ApplicationController def index api_instance = OpenapiClient::DefaultApi.new begin result = api_instance.api_mismatch_fruits_get(debug_return_type: 'String') # ここ p result p JSON.parse(result) rescue OpenapiClient::ApiError => e puts "Exception when calling DefaultApi->api_mismatch_fruits_get: #{e}" end end end
続いて、追加したコントローラのroutesを作成します。
Rails.application.routes.draw do namespace :api do namespace :mismatch do # ... # 追加 namespace :workaround do resources :fruits, only: [:index] end end end end
動作確認
curlで
% curl 'http://localhost:3002/api/mismatch/workaround/fruits'
としたところ、クライアントアプリは正常終了しました。また、サーバの情報がログへ出力されていました。
Started GET "/api/mismatch/workaround/fruits" for 127.0.0.1 at 2022-05-28 07:21:53 +0900 Processing by Api::Mismatch::Workaround::FruitsController#index as */* ETHON: performed EASY effective_url=http://localhost:3001/api/mismatch/fruits response_code=200 return_code=ok total_time=0.027292 # p result した内容 "{\"tags\":[\"\xE8\xBF\x91\xE6\x89\x80\"],\"shop\":\"\xE3\x82\xB3\xE3\x83\xB3\xE3\x83\x93\xE3\x83\x8B\",\"fruits\":[{\"name\":\"\xE3\x82\x8A\xE3\x82\x93\xE3\x81\x94\"},{\"name\":\"\xE3\x81\xBF\xE3\x81\x8B\xE3\x82\x93\"},{\"name\":\"\xE3\x81\xB6\xE3\x81\xA9\xE3\x81\x86\"}]}" # p JSON.parse(result) した内容 {"tags"=>["近所"], "shop"=>"コンビニ", "fruits"=>[{"name"=>"りんご"}, {"name"=>"みかん"}, {"name"=>"ぶどう"}]} Completed 204 No Content in 30ms (Allocations: 1228)
これにより、 enum
の定義にない値をレスポンスするAPIがあったとしても、APIクライアントでAPIを呼ぶ時に debug_return_type
に String
を渡せば良さそうと分かりました。
余談:committee-rails を使ってOpenAPIスキーマとレスポンスのズレに気づく
ここから先は余談です。
もし手元にあるAPIの場合は、OpenAPIスキーマと実際のレスポンスのズレに気づきたいです。
そこで、rspec-rails
でテストコードを書く時に committee
と committee-rails
も組み合わせることで、ズレが発生したら気づけるようにします。
環境構築
gemの追加
サーバのRailsアプリにあるGemfileに、以下を追記します。なお、 committee-rails
を入れると committee
も入ります。
group :development, :test do # OpenAPIスキーマをrspecでテストする用 gem 'rspec-rails' gem 'committee-rails' end
追加し終わったら bundle install
しておきます。
Rspecのセットアップ
rspec-railsのREADMEに従い、 bin/rails generate rspec:install
します。
rspec/rspec-rails: RSpec for Rails 5+
% bin/rails generate rspec:install create .rspec create spec create spec/spec_helper.rb create spec/rails_helper.rb
rails_helper.rb に必要な設定を追加
committee-railsのREADMEに従い、設定を追加します。
willnet/committee-rails: rails and committee are good friends
RSpec.configure do |config| # ... # committee-railsのオプション config.add_setting :committee_options config.committee_options = { schema_path: Rails.root.join('openapi.yaml').to_s, query_hash_key: 'rack.request.query_hash', parse_response_by_content_type: false, } # committee-railsのメソッドを利用可能にする include Committee::Rails::Test::Methods end
以上で設定は完了です。
テストコードを作成・実行
続いて、テストコードとして Request Spec spec/request/api/mismatch/fruits_controller_spec.rb
を作成します。
「enumで定義している値のみレスポンスする」のが正常系であるため、以下のテストコードとします。
require 'rails_helper' RSpec.describe 'Api::Mismatch::FruitsController', type: :request do let(:response_body) { JSON.parse(response.body) } describe 'GET /api/mismatch/error/fruits' do it '正常系' do get '/api/mismatch/error/fruits' assert_request_schema_confirm assert_response_schema_confirm(200) end end end
specを実行するとエラーになります。
% bin/rails spec F Failures: 1) Api::Mismatch::FruitsController GET /api/mismatch/fruits 正常系 Failure/Error: assert_response_schema_confirm(200) Committee::InvalidResponse: "近所" isn't part of the enum in #/paths/~1api~1mismatch~1fruits/get/responses/200/content/application~1json/schema/properties/ta/items # ... # --- Caused by: --- # OpenAPIParser::NotEnumInclude: # "近所" isn't part of the enum in #/paths/~1api~1mismatch~1fruits/get/responses/200/content/application~1json/schema/properties/gs/items # path/to/rails_7_0_openapi_server_app/gems/openapi_parser-0.15.0/lib/openapi_parser/schema_validator.rb:63:in `validate_data'
これにより、OpenAPIスキーマとAPIのレスポンスが異なる場合は、テストが失敗することで気づけそうです。
ソースコード
Githubに上げました。