はじめに
前回の記事では、OpenAPIで新しいウェブAPIを定義する際に、yamlのままで読みやすいようにファイル構成等を工夫した話をしました。
今回はそのAPIスキーマを使って、Railsでスキーマ駆動開発を実現するにあたって利用しているツール類についてお話しようと思います。
また、合わせて、その中での利用方法の工夫や、問題となったツールの不具合等についても共有いたします。
サービス構成
前回に引き続き、APIGatewayと教材管理サービスの間、バックエンド内部の通信を対象としています。
APIGateway(クライアント側)と教材管理サービス(サーバー側)は共にバージョン5系のRailsアプリです。1
レポジトリ一覧
主に4つのGitレポジトリを使って開発します。
- サーバー側Railsアプリ
- クライアント側Railsアプリ
- OpenAPIドキュメント
- openapi-generatorが生成するAPIクライアントgem
それぞれのレポジトリと利用しているツールの構成の概観はこのような感じです。
サーバー側Railsアプリ
サーバーAPIはgrapeを利用して書かれており、レスポンススキーマの整形にactive_model_serializerを利用しています。
サーバー側のGitレポジトリは、OpenAPIドキュメント用レポジトリをGit Submoduleとして持ちます。
クライアント側Railsアプリ
クライアント側は、後述するように、open-api-generetorで生成されたAPIクライアントgemを使ってサーバーにアクセスします。
APIクライアントgemは、通常のgemと同様に、Gemfile
を通じて取得されます。
開発の流れ
現在、OpenAPI関連の開発は次のようなステップで進められています。
便宜上、「サーバー側の開発 => クライアント側の開発」という順で書いていますが、 スキーマ駆動開発を採用しているので、両者は並行して進めることが可能です。
- OpenAPIドキュメントのレポジトリで、API定義を修正するPRを作成し、レビューの後、masterブランチにマージします。
- PRが作成されたタイミングのCIで、
swagger-cli
によるスキーマ定義のバリデーションが行われます。(バリデーションに失敗したらマージ不可) - PRがマージされたタイミングのCIで、
swagger-cli
によって全てのスキーマ定義ファイルが単一のJSONファイルに連結され、作成されたファイルがmasterブランチにコミットされます。 - PRがマージされたタイミングのCIで、
openapi-generator
によってAPIクライアントgemが生成され、別途存在するgem用レポジトリのmasterが更新されます。
- PRが作成されたタイミングのCIで、
- (ここからサーバー側の実装)
- APIレスポンスのコンポーネント単位のスキーマがOpenAPI定義に沿うように、
active_model_serializer
のクラスを実装します。- 実装したクラスは、
json-schema
のgemを使ったテストコードでチェックされます。
- 実装したクラスは、
active_model_serializer
のクラスを使って、APIエンドポイントを実装します。- 実装したエンドポイントは、
committee-rails
を使ったテストコードでチェックされます。
- 実装したエンドポイントは、
- (ここまでサーバー側の実装) (ここからクライアント側の実装)
openapi-generator
で生成されたAPIクライアントgemのモデルクラスに対して、APIスタブ用のfactory_bot
を定義します。- 出力がOpenAPI定義に反していないことを確認するために、factoryに対するテストコードを書いています。検証にはAPIクライアントgemのモデルクラスの機能を利用しています。
- その他必要なクライアント側の実装を行います。
- サーバーへのアクセスには、
openapi-generator
の生成したAPIクライアントgemを利用します。 - クライアント側のテストコードでは、上記のfactoryを使ってAPIレスポンスのスタブを組み立てます。
- サーバーへのアクセスには、
- (ここまでクライアント側の実装)
利用ツール
swagger-cli
OpenAPI関連のツールは、ものによってyaml形式やファイル分割への対応状況がまちまちなので、どんなツールでも利用可能な単一のJSONファイルの形式に変換するために採用しました。
また、スキーマ定義がOpenAPI仕様に沿って書かれているかのバリデーション機能も備えているので、それも合わせて利用しています。 他のツールに比較して、間違っている箇所が把握しやすいエラーメッセージが出力されるように感じます。
公式のdockerイメージが配布されており、これを利用することで上記のような各タイミングでCIで自動実行することが簡単にできます。
committee-rails
スキーマ駆動開発の実現のため、可能ならばサーバー側もコードの自動生成を利用したかったのですが、Rails対応で良い感じのものが見つかりませんでした。 そこで、Rails + OpenAPIで広く行われているように、自動テストにCommitteeを用いたスキーマチェックを導入する形でサーバーの実装がOpenAPI定義に沿っていることを担保するようにしました。
前述の通り、サーバー側RailsアプリはOpenAPI定義ファイルのレポジトリをGit Submoduleとして持っています。
そして、次のようなモジュールを作り、スキーマチェックを行う各specファイルでinclude
することで、自動でファイルパスなどの設定が行われるようにしています。
module CommitteeHelper def committee_options @committee_options ||= { schema_path: Rails.root.join("openapi_submodule/path/to/openapi.json").to_s, query_hash_key: "rack.request.query_hash", parse_response_by_content_type: false, prefix: "/api/prefix", } end end
スキーマ定義のパース周り2でOpenAPI仕様をカバーしていない部分も多く、次のような不具合に悩まされました。
不具合1: ファイルの分割
こちらのブログで報告されているように、分割したファイルへの$ref
参照周りにバグがあります。
同様の問題がgithubのissueで報告されており、こちらでも、上記のブログ記事のように、参照先のファイルをpaths
やcomponents
のようなトップレベルから書くことで解決できるとされています。
しかし、それだとファイルを分割したメリットがだいぶ失われてしまうので、今回は前述のように、swagger-cli
で連結したJSON形式の定義ファイルを用いることでテスト時にファイル結合を行う必要がないようにしています。
不具合2: $refとnullableの同時使用
$ref
で参照したスキーマを参照元でnullableにする方法について、OpenAPI仕様では明示的な定められておらず、実現には一工夫が必要となります。
例えば、多くのツールにとっての対応済みの最新バージョンであるOpenAPI 3.0の記法では、type: null
のスキーマ定義が認められていません。3
また、$ref
ディレクティブは、他の並立するほとんどを上書きしてしまうので、OpenAPI定義の記法であるnullable: true
と同時に利用しても、nullは許容されません。
# OpenAPI 3.0では不可 some_property: allOf: - type: null - $ref: path/to/reference.yml#schema_name # nullableにならない some_property: $ref: path/to/reference.yml#schema_name nullable: true
OpenAPI 3.0で利用可能な記法としては次のように、参照先をallOf
で包んでnullable
ディレクティブを上書きさせない記法と、type: null
を使わずにnullしか設定できないスキーマを定義してanyOf
で並列させる記法の、大きく2つがあります。4
# allOfで包む記法 # Committeeではnullableとして扱ってくれない some_property: nullable: true allOf: - $ref: path/to/reference.yml#schema_name # anyOfで並べる記法 some_property: nullable: true anyOf: - $ref: path/to/reference.yml#schema_name - type: string nullable: true enum: [null]
しかし、Committeeは前者のallOf
で包む記法ではnullを許可してくれません。
利用するツールがCommitteeだけであれば、後者の方法を使えば良いだけの話ですが、OpenAPIGeneratorには後述のanyOf
に関するバグがあったので、ダブルバインドに陥ってしまいました。
json-schema
レスポンスのスキーマチェックは前述のCommitteeで一通りできるのですが、せっかくコンポーネント構成を工夫したAPI定義をしたので、コンポーネント単位でもスキーマチェックができるようにしています。
具体的には、前述の開発の流れにあるように、OpenAPIで定義されているコンポーネントの単位でactive_model_serializer
のクラスが実装されるので、それに対するユニットテストでOpenAPI定義を利用できるようにします。
狭いスコープのテストでスキーマチェックを行うことによって、例えばnil/非nilの場合わけや各enum値が全て正しく出力されるかなど、細かい条件での動作確認がやりやすくなっています。
Committeeをはじめとした一般的なOpenAPI検証ツールでは、スキーマチェックはレスポンス単位で行われ、コンポーネント単位のチェックはできないので、通常のJSON Schemaの検証用gemであるjson-schema
を使ってスキーマ定義と実際の出力の照合を行います。
また、OpenAPI定義のルートにcomponentディレクティブを置かなかったために結合されたJSON形式の定義ファイルから目的のコンポーネントを見つけ出すのが難しかったので、こちらの検証ではJSONファイルではなく、結合前のYAML形式のコンポーネント定義ファイルを直接参照しています。
openapiの記法に合わせた機能拡張
OpenAPIに置けるスキーマ定義の記法には、一部一般的なJSON Schemaの記法とは異なるところがあるので、その差異を吸収するために、gemで推奨されている方法で、次の2つの点で、スキーマ定義クラスを拡張しています。
nullable: true
の形式でnull許容を設定可能にする$ref
で参照先を指定するときに、参照元ファイルからの相対パスで指定できるようにする
ただし後者については、参照元ファイルのファイルパスを動的に取得する手段が見つからなかったので、「コンポーネント定義ファイルは全てcomponents/
ディレクトリ以下にフラットに配置される」というローカルな定義ルールを利用して、ちょっと強引に実現させました。
拡張したスキーマ定義はCommitteeと同様に専用モジュールを用意し、assert_valid_json_schema
という独自のアサーションメソッドを使って簡単にテストが書けるようにしています。
module OpenAPIComponentHelper class CustomizedSchema << JSON::Schema::Draft3 # 元のgemから拡張したクラスの定義は省略 end # draft3指定時にvalidationに機能を拡張したクラスを利用するようにセットする JSON::Validator.register_validator CustomizedSchema.new def assert_valid_json_schema(target_json, schema_file_name, component_name) schema = YAML.load_file "#{COMPONENT_DIR}/#{schema_file_name}" begin # OpenAPIGeneratorが対応しているnullableと`$ref`の同時使用時の記法の関係で、draft3のバージョンを指定する必要がある JSON::Validator.validate! schema, target_json, strict: true, fragment: "#/#{component_name}", version: :draft3 rescue JSON::Schema::ValidationError => e # 失敗したデータの中身を表示するため、一度キャッチしてエラーメッセージを組み立て直す raise "Failed to JSON schema validation: #{e.message}\ngiven data:: #{target_json}" end end end
openapi-generator-cli (Ruby client)
APIクライアントの実装は、OpenapiGeneratorが生成するRubyクライアントが本番で利用可能な水準だったので、それを使っています。
生成されるコードは.gemspec
形式のファイルを含めたgemの形式で出力されるため、OpenAPI定義を管理する場所とは別に新しくGitレポジトリを作り、通常のgemと同じようにGemfileで指定する形で各アプリケーションで利用できるようにしました。
そして、開発の流れの通り、gemレポジトリの更新はCIによって自動化されており、特に意識して管理する必要がないようになっています。
機能面ではオープンタイムアウトの設定と結果のキャッシュ関連が求めている内容に対して不足していたので、アンチパターンになりますが、生成されたクライアントクラスを継承を使って元クラスの実装の詳細に介入する形で拡張しています。 また、生成されたクライアントを使うにあたっては、次のような不具合に悩まされました。
不具合1: 中途半端な型チェック
生成されたRubyクライアントは型チェック機能が備わっており、サーバーからのレスポンスがスキーマ定義に沿っていない場合には例外が発生するようになっています。 しかし、型チェックの実装箇所が少し特殊で、モデルクラス単体で利用したときに型チェックの恩恵を十分に受けるには実装方法に少々工夫が必要となっています。
レスポンスのモデルクラスのインスタンスに値を設定する手段としては、「1. コンストラクタの引数で設定する」「2. attr_accessor
で生成されるセッターメソッドを使う」「3. build_from_hash
というクラスメソッドを使う」という3つの方法が用意されているのですが、クラスメソッドを使う3番目の方法以外では、セットされる値の検証をしてくれないのです。
値をセットした後に検証するためのvalid?
のようなメソッドも用意されていますが、nullや最大最小のチェックしかやってくれず、例えばオブジェクト型のプロパティに文字列がセットされていても異常を検知してくれません。
APIサーバーからのレスポンスをパースする際にはbuild_from_hash
というクラスメソッドが使われており、これをつかって値をセットしたときにしか、型やenumの値の正しさが検証できないようになっています。
不具合2: oneOf/anyOfに非対応
anyOf
やoneOf
を使ったスキーマ定義すると、AnyOfXXXYYY
やOneOfXXXYYY
のような名前の存在しないクラスを参照するコードが生成されてします。
XXX
やYYY
はanyOf
やoneOf
で組み合わされた型に応じたクラス名なので、対応するクラス定義を生成するコードが未実装なのでしょうか。
anyOf
が使えないため、前述の方法でCommitteeに$ref
の参照先をnull許容させることができず、結果的に$ref
の参照先をnullableにすること自体を諦める結果になりました。
factory_bot
一般的には、ActiveRecordのモデルクラスに対してテスト用のデータを生成するために使用されますが、ここではopenapi-generator
が生成したAPIレスポンスのモデルクラスに対して使用し、アプリケーション内部での自動テストのためのデータの生成にも使用しています。
前述の通りopenapi-generator
が生成するモデルクラスは型のサポートが不十分で、普通にfactoryを定義するだけだとOpenAPI定義の更新への追随を忘れ、facotoryと実際のデータに乖離が発生するおそれがあるので、変則的ですが、factoryに対しても下のようなRSpecのテストを書いて、factoryのデフォルトがOpenAPI定義に沿っていることを確認できるようにしています。
# factoriesはFactoryBot::Factoryクラスの配列 factories.each do |f| describe f.name do it "デフォルトでvalidなオブジェクトが作られる" do # build_from_hashはenumの値が不正な場合に例外が発生する # FactoryBot.buildでは型チェックが行われないためbuildclass.build_from_hashを使用する obj = f.build_class.build_from_hash(build(f.name).to_hash) expect(obj.list_invalid_properties).to be_empty end f.defined_traits.map(&:name).each do |trait| context "traitが#{trait}の場合" do it "validなオブジェクトが作られる" do obj = f.build_class.build_from_hash(build(f.name, trait).to_hash) expect(obj.list_invalid_properties).to be_empty end end end end end
終わりに
OpenAPIを用いたスキーマ駆動開発に挑戦してみて、gRPC・GraphQLという他技術と比べたときのエコシステムの弱さを正直感じました。
元はAPI仕様の記述を目的として作られた背景があるためか、ドキュメント生成関連の充実ぶりに比べて、コード自動生成関連のツールが少なかったです。 サーバーのコード生成についても、スタブとして開発中に一時的に利用するためのものはそれなりにある一方で、定義修正と実装のサイクルを繰り返せて行けそうなツールはほとんど見つかりませんでした。5
開発主体についても、OpenAPIのスキーマ仕様については大きなコミュニティが存在しているものの、ツールの開発はほとんどが個人レベルに任されており、GoogleやMetaのような巨大企業が開発したgRPC・GraphQLと比べると保守体制が弱いものが多いように感じます。 最新バージョンのOpenAPI 3.1が公開されて1年以上経つのにそれをサポートしているツールがほとんどない点と、利用するツールよって定義の書き方の細かい部分を調整する必要が出てくる点が特に辛いところです。
しかし、JSON形式のレスポンスを用いるシンプルさと、サーバー側・クライアント側共に既存の実装を大きく変えることなく導入できる点は、gRPCやGraphQLには真似できない大きな強味なので、OpenAPI関連のエコシステムがもっと発展し、スキーマ駆動開発を導入への障壁が下がって広く導入されていって欲しいと思います。
We are hiring!
株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。
カジュアル面談も行っています。 お気軽にご連絡ください!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。
-
5系のバージョンはサポート期間を過ぎてしまっているので、両アプリを年内にバージョン6.1まで上げられるように鋭意アップデート作業中です!!↩
-
CommitteeのOpenAPI定義のパース周りの実装は、open_api parser。↩
-
最新のOpenAPI 3.1では
type: null
の利用が可能となりましたが、3.1に対応済みのツールがまだほとんどないのが現状です。↩ -
参考: https://stackoverflow.com/questions/40920441/how-to-specify-a-property-can-be-null-or-a-reference-with-swagger↩
-
別言語になりますが、Goのopenapi-codegenというツールは、インターフェース定義を中心に生成される形になっており、欲しかったものに近いと感じました。↩