👌

Committee × Railsでのスキーマ駆動なAPI開発|Offers Tech Blog

2022/08/02に公開

概要

Offers を運営している株式会社 overflow の磯崎です。
テックブログを書き始めて早数ヶ月、ただひたすら OpenAPI について書き記しているのが私です。そろそろ違うことを書きたいです。

さて今回は、定義した API と実装した API の response が一致しているかを rspec でテストしていく模様を記述します。

弊社でのスキーマ駆動 開発フロー

  • API 定義
  • フロントエンド開発
    • 定義した API から通信処理を自動生成し、それを叩く(API 定義をベースに通信)
  • バックエンド開発
    • 定義した API 通りに開発
    • 定義通りの response を返す実装ができているかをテストで保証(API 定義との乖離防止)← 今回の記事はこちらについて書いています

何も特別なことはしておらず、まず API を定義し、その後にその定義との乖離が起きないようにフロント、バックエンドと並行して開発を進めていきます。

ここでバックエンドが保証すべきものとしては、「定義した API とのずれがないこと」なのでそれをテストでカバーしていこうといった取り組みです。

使用するgem

https://github.com/willnet/committee-rails

これを入れることで、assert_response_schema_confirm という定義した API とテスト対象の API response が一致しているかを確認してくれるメソッドが使用できるようになります。

初期設定

spec/rails_helper.rb 内の rspec の設定に committee の設定を追加します。

  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('schema', 'api.yaml').to_s,
    prefix: '/v1',
  }

ほぼ readme 通りに設定しています。
細かく設定したい場合は、大本である committee を参照してください。

バージョニングをしている場合など、request path に prefix をつけている場合は、prefix を設定します。

使用例

実際に動かしてみるのがわかりやすいので、テストを実行してみます。

今回はコメントを取得する API を例とします。

 '/comments/{comment_id}':
    parameters:
      - schema:
          type: string
        name: comment_id
        in: path
        required: true
    get:
      summary: コメント取得
      tags: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  title:
                    type: string
                  author:
                    type: string
      operationId: get-comments

このようにテストを書くだけで、とっても簡単に response と定義した schema が一致しているかを確認できます。

describe 'GET /api/v1/comments/:id' do
  let(:comment_id) { 1 }

  it 'ok' do
    get "/api/v1/comments/#{comment_id}"
    assert_response_schema_confirm(200)
  end
end

テスト成功パターン

まずは成功パターンです。
以下のように定義した response 通りに json を返してあげる API を作成しました。

def get_comment
  render json: {
    title: 'title',
    author: 'name'
  }, status: :ok
end

実行結果

テストが通りました。

GET /api/v1/comments/:id
  ok

1 example, 0 failures

テスト失敗パターン

返り値の型が違う

ここでは失敗の例として、srting として定義した title を int で返してみます。

def get_comment
  render json: {
    title: 1,
    author: 'name'
  }, status: :ok
end

実行結果

きちんと、string で定義されているのに int が返ってきてますよと忠告してくれますね。

GET /api/v1/comments/:id ok
Failure/Error: assert_response_schema_confirm(200)

Committee::InvalidResponse:
  #/paths/~1comments~1{comment_id}/get/responses/200/content/application~1json/schema/properties/title expected string, but received Integer: 1

responseに必ず含まれるプロパティには、requiredをつける

先程の例をそのまま使用します。
次は、「定義した値が response に含まれていない」場合どうなるのかをみていきます。
パッと期待する挙動としては、テストが失敗し、〇〇が含まれていませんと忠告を受けることかと思います。

タイトルを response から除外して、テストを実行してみます。

def get_comment
  render json: {
    author: 'name'
  }, status: :ok
end

普通にテストが通ってしまいました。

GET /api/v1/comments/:id
  ok

1 example, 0 failures

このように、「response に必ず含まれる key」の場合は、API 定義の際に required をつける必要があります。
先程の api.yaml に required を追加します。

 '/comments/{comment_id}':
    parameters:
      - schema:
          type: string
        name: comment_id
        in: path
        required: true
    get:
      summary: コメント取得
      tags: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  title:
                    type: string
                  author:
                    type: string
                required: #こちらを追加
                  - title
                  - author
      operationId: get-comments

そして、再度をテストを実行するとこのように失敗します。

Committee::InvalidResponse:
       #/paths/~1comments~1{comment_id}/get/responses/200/content/application~1json/schema missing required parameters: title

required のタイトルがないですよと忠告してくれるようになります。
なので、response に必ず含まれるものとして定義しているものには required をつけます。

additionalProperties: falseは絶対につける

次は、「定義していない値が response に含まれている」場合どうなるのかをみていきます。
これもパッと期待する挙動としては、テストが失敗し、余計な〇〇が含まれていますと忠告を受けることですね。

location という余計な値を突っ込んでみました。

def get_comment
  render json: {
    title: 'title',
    author: 'name',
    location: 'tokyo'
  }, status: :ok
end

このまま実行すると、普通にテストが通ります。

GET /api/v1/comments/:id
  ok

1 example, 0 failures

これは地味に危険です。本来出すべきではない値のチェックが行われずに、テストが通ってしまいます。
極端な例をあげると、ユーザーを返す API で password も紛れ込んでいた場合などは、それをテストで落とすことができません。

これを解決するためには、additionalProperties: false を API 定義ファイルに追加します。

'/comments/{comment_id}':
    parameters:
      - schema:
          type: string
        name: comment_id
        in: path
        required: true
    get:
      summary: コメント取得
      tags: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  title:
                    type: string
                  author:
                    type: string
                required:
                  - title
                  - author
                additionalProperties: false #これを追加
      operationId: get-comments

再度同条件でテストを実行します。

 Committee::InvalidResponse:
       #/paths/~1comments~1{comment_id}/get/responses/200/content/application~1json/schema does not define properties: location

定義されていない location というのが紛れ込んでいますよ!という文章とともにテストが落ちるようになりました。

このように response 内に定義していないものが紛れ込んでいないかもチェックできるので、この additionalProperties: false は原則つけて良いものだと考えています。個人的にはデフォルトで設定しておいてほしいレベルです。

nullable: trueの取り扱い

OpenAPI3 から出現した nullable: true は、付与することで値に null が入っていてもテストが通るようになります。

def get_comment
    render json: {
      title: nil,
      author: 'name',
    }, status: :ok
  end

nullable: true を付与しないと、テスト失敗します。

Committee::InvalidResponse:
       #/paths/~1comments~1{comment_id}/get/responses/200/content/application~1json/schema/properties/title does not allow null values

nullable: true を title に追加して、再度実行してみるとテストが通ります。
また、key 自体が存在しないパターンでもテストが通ります。

  '/comments/{comment_id}':
    parameters:
      - schema:
          type: string
        name: comment_id
        in: path
        required: true
    get:
      summary: コメント取得
      tags: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  title:
                    type: string
                    nullable: true #これを追加
                  author:
                    type: string
                required:
                  - title
                  - author
                additionalProperties: false
      operationId: get-comments

OpenAPI での null の扱いについては、様々な意見が渦巻いておりますが個人的にはこのように考えております。

  • nullable: true は原則使わない。required をつけて空文字など初期値を入れておく
  • それでも null を返す必要がある場合は、response の key 自体に含めない

とにかく、response を null or 文字列など複雑なものにしたくないからです。
response のパターンが増えれば、当然ながらフロントエンド開発に負担をかけることになります。
API の実装者としてはシンプルで扱いやすい設計を心がけるという初心は忘れずにいたいです。

そして、弊社は json builder として、active_model_serializers を使用しています。
シリアライザーは、has_onebelongs_to で関連するものがない時は、key 自体が response に含まれません。
なので、そこでもばらつきがでないように、上記のようなことを心がけて API の response を設計しております。

まとめ

スキーマ駆動開発における、API の response の整合性の保証を rspec で行う方法について書きました。
テスト自体は gem をいれることですごくシンプルに記述できるので、快適にテストを追加していけております。
課題としてあがるのは、どちらかというと OpenAPI の運用面であり、上述した注意点を守らないと意図しない API が爆誕する可能性もあります。

ただし、しっかりと API さえ定義できていればあとはシンプルな記述で response のチェックが行えるので、可読性の高いコードで結果を保証できます。
なので、スキーマ駆動開発という名の通り、この開発フローにおいてはスキーマの定義が最も重要です。
できるだけ、人間が気をつけなくても良いような仕組みを作り、安心安全に開発を進めていくために引き続き色々と試していきます。

これにて、私の OpenAPI 連載シリーズ終了です。
関連記事を載せてありますので是非合わせてご一読ください。

ちなみにこれらスキーマ駆動開発の経験、課題感を踏まえ現在部分的な GraphQL 移行が進んでます。

関連記事

https://zenn.dev/offers/articles/20220411-open-api-schema
https://zenn.dev/offers/articles/20220530-openapi_stoplight
https://zenn.dev/offers/articles/20220620-openapi-generator

Offers Tech Blog

Discussion