広告技術部のUT@mocyuto です。
この記事は Gunosy Advent Calendar 2022の10日目の記事です。 前回の記事はkoizumiさんの Snyk IaC + reviewdog + aquaではじめるDevSecOps - Gunosy Tech Blog でした。
今回の記事では、OpenAPIでRailsとTypeScriptのスキーマを共有する方法に関して紹介します。
概要
弊社の管理画面では長くRailsを使っており、最初のPRは2013年の10月でした。 長らくの運用で複雑になったRailsのerbだった部分をReactを使って書き換えるというプロジェクトが始まりました。 すでにいくつかの新規ページはReactで作成されていましたが、大きなページを置き換えるというのは心理的ハードルと時間的コストから進められずにいました。
しかし、メンバーが入れ替わると複雑な仕様をすべて把握している人はおらず、以下の様な問題が発生してしまっていました。
- 大量のフォーム入力による複雑なロジック
- それを制御する大量のJQueryによるDOMの変更
- htmlとjsが分離しているため、どこで影響があるかわからない。
そのためReactによる書き換えプロジェクトをスタートしました。
課題
今回の書き換えのプロジェクトは弊社内でも最近流行っているモブプログラミングで実施しました。 システムが複雑であるため、メンバーのまだ知らない機能があったり、実は使われていない機能があったりと、書き換えは発掘作業のようでした。 しかし、チームの頑張りによって順調に書き換えを終わらせることができ、結合テストを実施する段階まで到達しました。
構成としては、Rails側のAPIとTypeScript側のaxiosでHTTP通信を行う一般的な構成です。 一般的なものと異なる点として、既存のページが大量にあるのでSPA(Single Page Application)ではなく、ページごとでサーバからtemplateを返し、ReactでDOMを構築するという方針を取りました。
ただ結合テストを実施していく上でバグがポロポロ出てきてしまいました。 基本的にはReact側のテストは充実させながら進んだので、フロントエンドで完結するバグはあまりなかったのですが、Rails側のAPIとの整合性が合っていないというバグが多く存在しました。 今回のリファクタ箇所ではPOSTによる登録でnullableなフィールドが多くありました。 nullableなフィールドは何も入れなければ登録されないので気付きづらく、複数人で結合テスト実施をしたことで気づくなど分かりづらいバグが発生していました。
課題の解決に向けて
結合テストを含め、リリースまでは漕ぎ着けたのですが、このままではまたフィールドを追加した際や今後運用していく中でバグが発生してしまうのが容易に想像できました。 そこでOpenAPIでインターフェースを共通化し、各フィールドを人間の手を介さないように自動生成することで、そのようなミスを減らす方針を固めました。 ただOpenAPIの導入に際して変更範囲を小さくしたいためRailsのControllerを変更せず、一括リプレースの必要がないスモールに入れられるものを欲していました。
選定
RailsのAPI仕様をもとにOpenAPIを生成し、TypeScript側でOpenAPIからコードを生成できることを選定上の要求とします。 理由として、新規にゼロからyamlを書いてRailsのコントローラーを生成というのは、すでに構築されている部分をやり直す必要があるためです。
Rails
Rails側で選定の際に検討したのは、以下です。
- rspec-openapi
- rspec_api_documentation
- rswag
- rspec_rails_swagger
結論としてはrspec-openapiに決めました。
以下が選定理由です。
- 通常のrspecからyamlを生成してくれる
- 新規のDSLを書く必要がない
- メンテされている
it 'POST hoge' do params = { hoge: :fuga } post '/hoge', headers: { 'Content-Type': 'application/json' }, params: params.to_json assert_request_schema_confirm # committeeによるrequestとschemaの乖離をチェック assert_response_schema_confirm(201) # committeeによるresponseとschemaの乖離をチェック end
specは上記のように書きます。
また同時にspecにcommitteeを使い、openAPIのyamlに aditionalProperties: false
を追加することで、 openAPIのフィールドの正しさもチェックするようにしています。
schema: type: object additionalProperties: false properties: name: type: string
TypeScript
TypeScriptに関してもすでにロジックは組み上がっていたので簡単にschemaからinterfaceを作ってくれるだけで良いのでそこだけを求めていました。
選定の際に検討したのは以下です。
- openapi_generator
- autorest
- aspida
この中からopenapi_generatorに決定しました。
選定理由としては、interfaceだけでよいので生成後のファイルが少ない、という理由が一番でした。 またjsのpackage外にyamlがあっても影響がないというのも重要でした。
ただ javaが必要だったりするので以下のようにdockerで作成するようにpackage.jsonを設定しています
{ "scripts": { "gen": "cd ..; docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli:v6.2.0 generate -i /local/openapi_docs/openapi/web.yaml -g typescript-axios -o /local/frontend/generated" } }
ハマったところ
rspec-openapiでは、OPENAPI=1
フラグを付けてspecを実行するとOpenAPIのyamlが生成されます。
このとき、yaml内の生成済みのフィールドは、新たな生成の実行によって消されないようになっています。
しかし当初の設定ではspec実行ごとになぜかフィールドがすべて消されていました。
そのため、複数のspecを回すと各specでOpenAPIのyamlが上書きされていき、目的のyamlが生成できませんでした。
Bad
post hoge_path(format: :json, params: params)
Good
post /hoge, headers: { 'Content-Type': 'application/json' }, params: params.to_json
上記のBadのようにRailsが生成するpathメソッドで呼び出すとparamsがrequest bodyではなくクエリパラメータとして生成されてしまいます。 paramsの階層が1段のフラットであれば生成されるのですが、入れ子の場合クエリパラメータが被ってしまうので上書きされてしまいます。
正しくはrequestBodyを生成するべきなので、Goodの書き方で書くことを推奨します。
まとめ
上記の方法でRailsのspecファイルがAPIスキーマの生成元となり、TypeScript側は自動で生成されたものを使うようになりました。 これにより、Rails側とTypeScript側でのスキーマのズレが発生しなくなり、安心して眠れるようになりました。 参考になれば幸いです。