committeeを使ったOpenAPI3のバリデーション

[committee](https://github.com/interagent/committee)を使ってOpenAPI3のスキーマファイルからリクエスト&レスポンスをバリデーションする方法と実際の開発に組み込む際の設定についてまとめます。

OpenAPI3とcommiteeについてはota42yさんの[登壇資料](https://rubykaigi.org/2019/presentations/ota42y.html)が参考になります。また、committeeについての記事はいっぱいあるので、ここでは実際の開発で取り入れるにあたって設定ファイルの書き方に絞って紹介したいと思います。

# committeeを使って実現したいこと

- API仕様書を作りたい
- スキーマファーストな開発をして、常に正しい状態にメンテナンスしていきたい
- リクエストのバリデーションなどのRailsが苦手な部分を楽に実装したい

## committeeを使う理由

- すでに運用中のサービスに途中から組み込める
- RSpecだけで実行したり、実際のリクエストのバリデーションに使ったりと用途に合わせて組み込みやすい
- 使い方がシンプルで導入が簡単

# 準備物

- OpenAPI3で記載したスキーマファイル（今回は準備されている前提）

スキーマファイルの管理は手動で管理していく方法とコードから自動生成する方法があります。前者の場合は、[Stoplight](https://stoplight.io/)のようなツールを使えば新しい人のキャッチアップも楽です。後者だと[swagger-blocks](https://github.com/fotinakis/swagger-blocks)のようなgemがありますが、OpenAPI3のサポートはまだ[実装まで行っていない](https://github.com/fotinakis/swagger-blocks/issues/110)ようです。 コードから自動生成の方がスキーマファイルと実際のコードとの差分が生まれないため、メンテナンスが楽になりますが現状良いgemが見つからなかったので自分は手動で管理しています。

- Gemfile

今回使うgemは以下です。

```rb
gem 'committee'

group :test do
  gem 'committee-rails'
end
```

committeeをテストにだけ利用する場合は、`:test`配下に書いて問題ないです。

# rspecで利用する

committeeをrspecに導入するためには[committee-rails](https://github.com/willnet/committee-rails)が便利です。

`rspec_helper.rb`等に以下の設定を入れれば使えるようになります。

```rb
RSpec.configure do |config|
  config.add_setting :committee_options
  config.committee_options = { schema_path: Rails.root.join('schema', 'schema.json').to_s, old_assert_behavior: false }
end
```

assert_schema_conformを実行すればスキーマを使って検証してくれます。

```rb
describe 'request spec' do
  include Committee::Rails::Test::Methods

  describe 'GET /' do
    it 'conform json schema' do
      get '/'
      assert_schema_conform
    end
  end
end
```

設定でold_assert_behaviorをtrueにした場合にはレスポンスしかチェックされずwarningが出る([参照](https://github.com/interagent/committee/blob/master/lib/committee/test/methods.rb#L62))のでfalseで設定しておくと良いと思います。

## 全てのrequest specでテストを実行する

常に最新の仕様にスキーマファイルを合わせるために、全てのrequest specで強制的にassert_schema_conformを書くには以下のように設定すれば可能です。

```rb
RSpec.configure do |config|
  config.include Committee::Rails::Test::Methods
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('schema', 'schema.json').to_s,
    old_assert_behavior: false
  }

  config.after(:each) do |example|
    next unless example.metadata[:type] == :request
    assert_schema_conform unless RSpec.configuration.skip_assert_schema_conform
  end

  config.before(:each) do |example|
    skip_flag = example.metadata[:skip_assert_schema_conform] || false
    config.skip_assert_schema_conform = skip_flag
  end
end
```

[こっちの記事](https://blog.kymmt.com/entry/auto-json-validation-with-committee)のように`ActionDispatch::Integration::Session`にpretendする方法もありますが、この[commit](https://github.com/willnet/committee-rails/commit/f7e4da6aa09f6a85b8707b56023c2168c413f7a0)で依存関係が崩れる[^1]ようになってpretendではエラーが出てしまうため、afterで実行するようにしています。

もし、assert_schema_conformをスキップしたいテストがあった場合には、下記のように記載してスキップ可能です。

```rb
describe 'GET /', type: :request, skip_assert_schema_conform: true do
  it 'skip conform schema' do
    get '/'
  end
end
```

このように設定しておけば、スキーマファイルを書き忘れた場合や、スキーマファイルの値が間違えていた場合などにCIで気づくことができ、常に最新の仕様を反映するように強制することができます。

[^1]: `undefined local variable or method 'integration_session' for #<#<Class:0x000056477f0bcee0>:0x00005647831c1fd0>`のエラーが出るようになる。integration_sessionは`ActionDispatch::Integration::Session`を呼び出している`ActionDispatch::Integration::Runner`側で定義されているメソッドなので参照できない。

# 実運用のサービスで使う

committeeではリクエスト&レスポンスのバリデーションが可能です。これを使えばRailsのController側で細かなparamsのチェックをしなくてよくなるので、テストだけでなく実際のリクエストにも適用するのがおすすめです。

設定は下記の通りで、`config/initializers/committee.rb`などのファイルに記述します。

```rb
return if Rails.env.test?

file = YAML.load_file(Rails.root.join('schema', 'schema.yml'))
open_api = OpenAPIParser.parse(file)
schema = Committee::Drivers::OpenAPI3::Driver.new.parse(open_api)

Rails.application.config.middleware.insert_before(
  ActionDispatch::Executor,
  Committee::Middleware::RequestValidation,
  error_class: Custom::RequestValidationError,
  schema: schema,
  strict: !Rails.env.production?
)

Rails.application.config.middleware.insert_after(
  ActionDispatch::Callbacks,
  Committee::Middleware::ResponseValidation,
  error_class: Custom::ResponseValidationError,
  schema: schema,
  validate_success_only: true
)
```

rspec実行時にも読み込まれるファイルなので、 `return if Rails.env.test?`してますが他にいい方法があれば知りたい...
各オプションについては [Committee::Middleware::RequestValidation](https://github.com/interagent/committee#committeemiddlewarerequestvalidation)を参考にしてください。上記ではstrictを本番だけOFFにすることで、開発環境では厳しくジャッジして本番では緩く運用する設定になっています。本番ではどのようなリクエストが来るのかわからないので、ある程度緩く運用したいケースもあるかと思います。

他にはエラー時にcommittee独自のエラーが返ってしまうので、独自のエラークラスを定義した方が良いと思います。

https://github.com/interagent/committee#validation-errors

開発中はrspecや動作確認でスキーマファイルの正当性を担保できるので、両方でcommitteeを動作させた方が良いかなと思います。また、本番で動作させることである程度サービスへの攻撃の防御や検知に使えるかなーと思いました。