Ruby版のOpenAPI generatorで生成したAPIクライアントが、enumの定義にない値をレスポンスで受け取るとエラーになってしまうので、回避策を実施してみた

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の不具合というよりは仕様な気がします。

 
目次

 

環境

今回はAPIを提供する側(サーバアプリ)とAPIを利用する側(クライアントアプリ) をRailsで作成してみます。

サーバアプリ

  • Rails 7.0.3
    • ポート 3001 で起動
  • テストコード用のgem

 

クライアントアプリ

 

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

こちらも --apirails 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 を指定した場合は、エラーになる
    • 上記の例では shopfruits
  • 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クライアントのメソッドを呼んでからエラーになるまでの流れを追いかけたところ、以下のようでした。

 

この流れの中で convert_type() の前にいい感じでデータを返す方法があるか探したところ、「return_typeString だったら、レスポンスボディを直接返す」という処理を見つけました。
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_typeString を設定することで、Stringなレスポンスボディをそのまま取得できそうでした。

ただ、Stringのままでは扱いづらいので、Stringなレスポンスボディを JSON.parse() すれば良さそうです。

 

回避策の実装

まずは新しくコントローラ(app/controllers/api/mismatch/workaround/fruits_controller.rb)を作り、OpenAPIクライアントのメソッドを呼ぶ時に debug_return_typeString を渡すようにします。

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_typeString を渡せば良さそうと分かりました。

 

余談:committee-rails を使ってOpenAPIスキーマとレスポンスのズレに気づく

ここから先は余談です。

もし手元にあるAPIの場合は、OpenAPIスキーマと実際のレスポンスのズレに気づきたいです。

そこで、rspec-rails でテストコードを書く時に committeecommittee-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に上げました。