gem 'rspec-openapi' を紹介したい

# はじめに

Request specからいい感じのOpenAPIドキュメントを作ってくれるgemのご紹介です。
バージョンアップで動作が変わることもあるので最新の情報はREADMEを参照してください。

https://github.com/exoego/rspec-openapi

## 検証環境

```
Rails : 7.0.4
rspec-openapi : 0.7.2
```

## インストールと実行

1\. インストール

testでしか使わないので `group: test` に追加すると良いでしょう。

```ruby
group :test do
  gem "rspec-openapi"
end
```

2\. 実行

rspecの実行に `OPENAPI=1` を追加するだけです。

```bash
OPENAPI=1 rspec
```

オプションがついているだけでrspecの実行に変わりないのでファイル指定やオプションはそのまま使えます。
試していませんがCIで生成させても良いと思います。

```bash
OPENAPI=1 rspec spec/requests/path/to
OPENAPI=1 rspec spec/requests/path/to -e 'hogehoge'
```

3\. 生成ファイルの確認

デフォルトでは `doc/openapi.yaml` に生成されます。
サンプルspecとその生成例が公式にあるので見ておくとイメージがつきやすいです。

https://github.com/exoego/rspec-openapi#example

## 設定やQ&A

README通りのものもありますが、設定しておくと便利なものやハマった事例を紹介します。

### requiredが付与されない

rspec-openapiはよろしくOpenAPIドキュメントを生成してくれますがrequiredは付与してくれません。
Request specの実行結果から生成するのでどれが必須なのかは判断できないのだと思われます。
生成後のyamlにrequiredを追記しても消えることはないので生成後に手動で追記しています。

### requestBodyがapplication/x-www-form-urlencodedで生成される

ちゃんとparams.to_jsonで送りheadersにContent-Typeを設定しましょう。

### summaryやdescriptionがわかりづらい

これらはspecのdescribeやitから生成されるのでこれらを変更しても良いです。
が、specだけ見たときにわかりづらくなるとそれはそれでなのでmetadataを追記してわかりやすくします。

```ruby
describe 'PUT /api/v1/posts/:id', openapi: {
  summary: 'POSTを更新する'
} do ...
```

```yaml
"/api/v1/posts/{id}":
  put:
    summary: POSTを更新する
```

descriptionも同じように書きたいのですが、似たような形でitに書くとdescribeの情報が上書きされ消えてしまいます。

```ruby
describe 'PUT /api/v1/posts/:id', openapi: {
  summary: 'POSTを更新する'
} do
  ...
  context 'POSTが存在しないとき' do
    it '404を返却する', openapi: { description: 'POSTが存在しない' } do ...
```

```yaml
"/api/v1/posts/{id}":
  put:
    summary: update # describeに書いたsummaryが反映されていない
    ...
    responses:
      '404':
        description: 'POSTが存在しない'
```

解決方法がわからないのでRSpec::OpenAPI.description_builderと独自metadataで解決しました。

```ruby
RSpec::OpenAPI.description_builder = -> (example) {
  example.metadata[:openapi_description] || example.description
}
```

```ruby
describe 'PUT /api/v1/posts/:id', openapi: {
  summary: 'POSTを更新する'
} do
  ...
  context 'POSTが存在しないとき' do
    it '404を返却する', openapi_description: 'POSTが存在しない' do ...
```

```yaml
"/api/v1/posts/{id}":
put:
  summary: 'POSTを更新する'
  ...
  responses:
    '404':
      description: 'POSTが存在しない'
```

### 生成yamlをspecごとに分割する

デフォルトだとすべてdoc/openapi.yamlに生成されるのでGitのコンフリクトが起きやすいです。
`RSpec::OpenAPI.path` にはブロックを渡せるのでファイルごとに分割できます。

```ruby
RSpec::OpenAPI.path = -> (example) {
  "doc/openapi/#{example.metadata[:file_path].match(%r{api/v1/(.*)_spec.rb})[1]}.yaml"
}
```

↑のように設定した場合は、 `spec/requests/api/vi/xxx_spec.rb` が `doc/openapi/xxx.yaml` に出力されます。
specごとは分割しすぎならREADMEのようにパスで分割しても良いと思います。

分割したファイルは [openapi-merge](https://www.npmjs.com/package/openapi-merge) などで結合できます。
個々のファイルを解消するのは比較的簡単ですし、それぞれでコンフリクト解消できれば結合ファイルは上書きするだけです。

### ステータスコードが重複するspecはどれか1つから生成される

post has_many comments となるようなモデルがあったとき、更新で404を返すのはpostがないとき/commentがないときの2パターンだとします。
Request specもそれぞれ書くと。

```ruby
describe 'PUT /api/v1/posts/:post_id/comments/:id' do
  context 'POSTが存在しないとき' do
    it '404を返却する', openapi_description: 'Postが存在しない' do
      put "/api/v1/posts/1/comments/1", params: { comment: { text: 'new text' } }.to_json, headers: headers

      expect(response).to have_http_status(:not_found)
    end
  end

  context 'Commentが存在しないとき' do
    let!(:post) { FactoryBot.create(:post) }

    it '404を返却する', openapi_description: 'Commentが存在しない' do
      put "/api/v1/posts/#{post.id}/comments/1", params: { comment: { text: 'new text' } }.to_json, headers: headers

      expect(response).to have_http_status(:not_found)
    end
  end
end
```

OpenAPIはどちらかのspecを元に生成されます。

```yaml
responses:
  '404':
    description: Commentが存在しない
    content:
      application/json:
        schema:
          type: object
          properties:
            errors:
              type: string
        example:
          errors: Couldn't find Comment with 'id'=1 [WHERE "comments"."post_id"
            = ?]
```

これはおそらく対処方法がないので手動でよろしく直すしかありません。
同じエラーコードでレスポンスが全く異なるケースはまれでしょうからdescriptionくらいかなと思います。

```ruby
describe 'PUT /api/v1/posts/:post_id/comments/:id' do
  context 'POSTが存在しないとき' do
    it '404を返却する', openapi_description: 'PostまたはCommentが存在しない' do
  ...
  context 'Commentが存在しないとき' do
    let!(:post) { FactoryBot.create(:post) }

    it '404を返却する', openapi_description: 'PostまたはCommentが存在しない' do
```

```yaml
responses:
  '404':
    description: PostまたはCommentが存在しない
```

### exampleが毎回変わる

Fakerを使っているときに起きます。
手っ取り早いのはexampleの生成をやめることです。

```ruby
# Disable generating `example`
RSpec::OpenAPI.enable_example = false
```

exampleを生成したいならFakerのseed値を固定する方法がIssueで提案されていました。

https://github.com/exoego/rspec-openapi/issues/76

環境変数 `OPENAPI` があるときはseed値を固定するので常に同じ値が生成されることになります。
ただし、生成するときだけ `OPENAPI=1` をつけるようにしないとFakerを使う意味がなくなるので注意が必要です。

## 終わりに

いくつか注意点はあるもののRequest specを書けばOpenAPIドキュメントが生成されるのは非常に楽です。
またプロダクトコードを直したら大抵はspecも直すのでドキュメントの追従性が高いもの魅力の一つです。

API仕様はコードです、と言わないようにうまく自動生成していきましょう！