valid,invalid

関心を持てる事柄について

既存のRESTish APIエンドポイントにOpenAPI定義を足していく試み

昨年、とあるアプリケーションのフロントエンドリニューアルプロジェクト*1の際に取り組んだ課題について書きます。Ruby, Railsの話が出てきますがRESTish APIの定義をどのように管理するかAPI定義が存在しない既存アプリケーションにどのようにドキュメントを足していくかという課題については言語・フレームワークを問わない内容です。

同プロジェクトでは以下のような状況であり、既存のAPIエンドポイントに関する情報をなんらかの形で提供したいというのがモチベーションでした。

  • Single page applicationやモバイルアプリのバックエンドとして動作するRESTish API server (Rails) が存在する
  • API serverの各APIエンドポイントに関するドキュメントが存在しない (または存在するが実装と一致しているか不明で信頼性が低い)
  • フロントエンドエンジニアはAPIの仕様を知らない、かつAPIのコードリーディングには時間がかかる

欲しかったものは「APIのin/outについてきちんと"型"が定義されていること」「実装と乖離していないことの保証」「定義が常に参照可能であること」でした。

やったこと

OpenAPI 3の採用

フロントエンドエンジニアが最も慣れていて、かつ習得難易度とかエコシステムとか考慮して今後の運用にも耐えうるであろう記述形式としてOpen API 3を選択しました*2

OpenAPIはWeb APIの仕様の記述形式です。または関連する周辺ツールです。かつてSwaggerと名乗っており今もその名を関した周辺ツールがたくさん残っていますが、現在はOpenAPIという名前でバージョン3が公開されています。バージョン2と3でだいぶ差がありますが、本記事ではOpenAPIというときにバージョン3を指すことにします。

OpenAPI 3によるAPI specificationの定義

同プロジェクトで必要とされたAPI群の定義をゼロから手動で書き起こしました。

初っ端から非人道的な話ですみません。自動化の道を探ったほうが良さそうにも一見思いました…が、自分もまったく詳しくないエンドポイントたちの中身を知らずに提供するのは望ましくない状況だったので、各エンドポイントのコードリーディングをしたりリクエストを投げて挙動を確認したりしながら書き起こすことで理解を得ました。

また、その過程で既存APIのバグ改修やパフォーマンス改善を行うこともありました。

定義が実装と乖離しないようにする

もともと存在していたYARDコメントがまさにそうだったのですが、記述形式がなんであろうと実装と乖離することが保証されていなければ信頼性は保てません。定義通りにAPIが動くことを継続的に検証する必要があります。

Rubyではcommitteeというgemがあり、これを利用してOpenAPI定義 (JSON or YAMLのファイル) に違反する挙動を検出できます。

また、既存APIに対して後追いで人間が記述する場合、その暖かみのある定義が間違っていないかどうかの検証にもなります。

テストで実装を検証する

committeeにはrequest validation, coercion, test assertion, stub機能などありますが同プロジェクトで使った機能はCommittee::Middleware::ResponseValidationのみです。これは名前の通りRack middlewareであり、アプリケーション(この場合は我々が提供するAPI server)のレスポンスを検証します。

test assertionをすべてのテストに都度書いていくのは面倒なため、API定義が書かれているすべてのエンドポイントについて自動的に検証を走らせるようにしています。OpenAPIによる定義が書かれていない場合は検証は行われません。

# config/environments/test.rb
Rails.application.configure do
  config.middleware.use(Committee::Middleware::ResponseValidation, schema_path: Rails.root.join("openapi/index.yaml"), raise: true)
end

committeeではエラーを検出した際のハンドラを自前で書くこともできますが、ここではシンプルにraise: trueを指定することでテストが単にfailします。たとえばこのようにテスト中にとあるエンドポイントにリクエストを発行するケースがあるとします。

describe "#GET /:language/api/users/:id", type: :request do
  let(:user) { create(:user, username: "butcher") }

  subject { get "/ja/api/users/#{user.id}" }
  
  it "returns 200" do
    subject
    expect(response).to have_http_status(200) 
  end
end

テストケースとしてはstatus codeを検証しているのみですが、middlewareがレスポンスを検証し、違反を見つけたら以下のようなエラーを吐きます。

  1) #GET /:language/api/users/:id
     Failure/Error: subject { get "/ja/api/users/#{user.id}" }

     Committee::InvalidResponse:
       true class is TrueClass but it's not valid integer in #/components/schemas/User/properties/is_teacher
     # ./spec/requests/api/users_spec.rb:160:in `block (3 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # OpenAPIParser::ValidateError:
     #   true class is TrueClass but it's not valid integer in #/components/schemas/User/properties/is_teacher
     #   ./spec/requests/api/users_spec.rb:160:in `block (3 levels) in <top (required)>'

注意事項としては、この検証はrack middleware層で行っているのでcontroller specでは実行されないということです(もちろんrack middlewareレイヤも込みで動くrequest spec, feature specでは実行されます)。今どきcontroller spec…*3?とお思いかもですが古いアプリケーションらしく一部はcontroller specが生き残っていたのでこれを機にガッとrequest specへ書き換えたりもしました。

これでテスト実行時にOpen API specificationと実装の乖離に気づけるようになりました。

…本当でしょうか?

実はこの検証だけでは実装との乖離を完全に防ぐことはできません。用意されたテストデータに対してしかテストを行っていないためです。たとえば上記コードでは user というテストデータを生成してそのデータに基づいてレスポンスを返すAPIを検証していますが、もしこのデータが変わったとしてもAPIが定義通りに動くことは保証しません。開発者が想定した正常系ド真ん中のテストデータによってしか動かない可能性もあります。

ステージング環境での違反検出と通知機構の整備

開発者が想定していないデータをカバーしたいときや、レスポンスのパターンが複雑でテストデータを用意しきれないようなときはどうすれば良いでしょうか。

そのような場合はテストですべてをカバーしようとするのではなく、実際に動くアプリケーション or "リアル"に近いデータ or "リアル"に近いリクエストで検証するアプローチが有効です。

一例として、QAやプレビューとして利用するステージング環境*4Committee::Middleware::ResponseValidationを有効にします。ただし、違反検出時にstatus code 500を返すと他の開発者や社内ステークホルダー諸氏が困るので「定義違反は検知するがレスポンスには手を加えず素通りさせる」「API開発者が気付けるように検知した違反の通知機構を作る」ようにします。

# config/environments/production.rb
Rails.application.configure do
  if ENV["USE_COMMITTEE_RESPONSE_VALIDATION"]
    config.middleware.use(
      Committee::Middleware::ResponseValidation, schema_path: Rails.root.join("openapi/index.yaml"),
      error_handler: -> (ex, env) { Raven.capture_exception(ex, tags: { committee_response_validation: true }) },
      ignore_error: true,
    )
  end
end

ステージング環境はRAILS_ENV=productionの前提でconfig/environments/production.rbに設定を記述しています。middlewareのoptionとしてignore_error: trueによって「定義違反は検知するがレスポンスには手を加えず素通りさせる」を、error_handler: -> (ex, env) { Raven.capture_exception(ex, tags: { committee_response_validation: true }) }によって「API開発者が気付けるように検知した違反の通知」を実現しています。この例ではSentryにエラーを通知しています。

f:id:ohbarye:20200428214538p:plain
Sentryへの通知をSlackに流す

ステージングとはいえ用意されたテストデータよりも遥かにバリエーションがあるのでこれにより実装のバグやOpen API定義の間違いなどに気づくことができました。

書き忘れましたが、ステージング環境のDBのデータがproductionのdumpをリストアしてマスクしたものであればさらに盤石です。テストする環境が本番に近ければ近いほどより信頼性の高いテストができるというわけです。

Kent C. Dodds先生の発言を思い起こさせます。

Swagger UIでいつでも参照できる状態にした

記述したAPI定義の参照容易性は大事です。

JSONまたはYAMLは定義そのものですが人間にとって可読性が高いものでは有りません。なのでdevelop branchが自動的にデプロイされるたびにSwagger UI (Open APIのviewer) で最新の定義がCOOOLな感じで見られるようにしました。

f:id:ohbarye:20200428215837p:plain
Swagger UIはこういうやつです

swaggerapi/swagger-uiのdocker imageを使っています。

おわりに

当初のゴールに対して以下のようにアプローチを行い、既存のRESTish API serverのドキュメント事情が"無"だったところを整備しました。

  • APIのin/outについてきちんと"型"が定義されていること」 <= 何もなかったところにOpenAPI定義を書いた
  • 「実装と乖離していないことの保証」 <= テストで実装を検証する + ステージング環境での違反検出と通知機構の整備
  • 「定義が常に参照可能であること」 <= OpenAPI viewerの提供

そして次にゼロからRESTish APIサーバを立ち上げるプロジェクトがあればSchema Firstでやっていく強い気持ちを得ました。

また、同プロジェクトにおいてやってないこと、今後やれそうなこととしてはコードの自動生成、OpenAPI specificationの自動生成 (from YARD to OpenAPI, grape-swagger)、検知した違反の自動修正等が挙げられます。これらも面白そうですがまた別のお話。

*1:https://speakerdeck.com/ohbarye/migration-from-react-native-to-pwa に詳しいです

*2:実質RESTish APIに関する形式としてはデファクトといっても良いと思います。API Blueprint, RAML...懐かしい名だ

*3:https://everydayrails.com/2016/08/29/replace-rspec-controller-tests.html

*4:production環境で検証するというハードコアな道もありますが一歩間違えると大惨事なのでまずはステージング環境から始めると良いと思います