Rails + RSpec + OpenAPI3 + Committeeでスキーマ駆動開発を運用するTips

こんにちは、タイミーデリバリー開発チームの宮城です。
今回は弊社のOpenAPI3ベースのスキーマ駆動開発の運用方法を紹介します。

TL;DR

  • 技術スタックは OpenAPI3, Swagger UI, Committee, ActiveModelSerializers
  • Committeeを利用してOpenAPI準拠のRequest Specを行う
  • OpenAPI3のrequiredキーワードに注意する

背景

タイミーデリバリーでは、RailsによるAPIサーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向けiOSアプリとしてSwiftを採用しています。
1つのモノリスRailsで利用者別にネームスペースを区切り、それぞれエンドポイントを提供しています。
サーバーサイドとクライアントサイドを分離し並行して開発を進めるためにスキーマ駆動開発を導入しました。スキーマ駆動開発の詳しい説明やメリットについては既に多くの記事が存在するため、ここでは参考にさせていただいた記事の紹介までとさせていただきます。

RubyKaigi 2019でOpenAPI 3について登壇しました - おおたの物置

スキーマファースト開発のススメ - onk.ninja

スキーマ駆動開発のススメ - Studyplus Engineering Blog

この記事では、実際にスキーマ駆動開発を開発フローに導入する際のTipsを記したいと思います。

技術スタックは ActiveModelSerializers, OpenAPI3, Swagger UI, Committee

RailsAPIサーバーを構築するにあたって、jsonの生成にはActiveModelSerializersを採用しました。
スキーマ定義にはOpenAPI3を採用し、Swagger UIでドキュメントを閲覧できるようにしています。
この3つに関しては近年では割とよくある一般的な技術スタックかなと思っています。
スキーマ定義を記述するYAMLファイルはRailsリポジトリに配置しています。

.
├── app/ ... アプリケーションのメインのソースコード
├── bin/            
├── config/         
├── db/             
├── etc/ 
│   └── docs/                   
│       ├── docker-compose.yml 
│       ├── README.md 
│       └── swagger/
│           └── swagger.yml 

ルート直下のetcディレクトリにはアプリケーションの実行には直接は関係ないものをまとめており、Dockerイメージのビルド時にdockerignoreしています。スキーマ定義以外ではデプロイの設定が入ってたりします。

スキーマ定義は分割していない

スキーマ定義は swagger.yml に全て記載しており、YAMLの分割などは一旦していません。
別のプロジェクトで細かく分割してディレクトリを分け、jsでディレクトリを監視しマージする運用をしていたことがありましたが、マージする仕組みを新規開発者に理解してもらうコストがかなり高く、運用しづらかった経験がありました。
今回は新規プロジェクトでAPIの数も少ないため、一旦1つのYAMLファイルのみで運用してみています。今の所チームメンバーからの不満は少ないですが、iosとvueでネームスペースを切っているのでそれくらいは分けてもいいかなと思っています。

Docker ComposeでSwagger UIを立ち上げる

Swagger UIを利用したドキュメントの閲覧方法は、どこかにホスティングしたりCircleCIのartifactsを使ったりはせず、Docker Composeで開発者自身のローカルで立ち上げるようにしています。
チームメンバーがDockerでの開発に慣れていたためこの選択をしました。
docker-compose.ymlはこんな感じです。

Swagger UIを立ち上げるdocker-compose.yml

version: '3'

services:
  redoc:
    image: redocly/redoc:latest
    container_name: redoc
    volumes:
      - ./swagger:/usr/share/nginx/html/swagger
    environment:
      SPEC_URL: swagger/swagger.yml
    ports:
      - "8081:80"
  doc:
    image: swaggerapi/swagger-ui:latest
    container_name: doc
    volumes:
      - ./swagger:/usr/share/nginx/html/swagger
    environment:
      API_URL: swagger/swagger.yml
    ports:
      - "8080:8080"

Swagger UIとRedocが立ち上がります。
RedocもOpenAPIベースのドキュメント表示ツールで、開発者が自由にどちらでも見ていいことにしています。

Committeeを利用してOpenAPI準拠のRequest Specを行う

スキーマ駆動開発では、開発を始める前にまずスキーマを定義しそれを信頼することが求められます。
しかし起こりうる課題としてスキーマと実装の乖離があります。スキーマの信頼性が失われると結局サーバーサイド開発者とクライアントサイド開発者の無駄なコミュニケーションが発生し、スキーマは形骸化してしまいます。
Railsの開発においてこの問題を防ぐために、committeeとcommittee-railsというGemを採用しました。

github.com

github.com

committeeは、OpenAPIで定義したスキーマを利用してリクエストとレスポンスの検証を行うミドルウェアを提供してくれます。
committee-railsはラッパーライブラリで、railsでの導入を簡単にしてくれます。

committee-railsrails_helper.rb に以下の記述を追記することで導入できます。

  # configured for committee-rails
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('etc', 'docs', 'swagger', 'swagger.yml').to_s,
  }
  include Committee::Rails::Test::Methods

スキーマが配置されているパスを設定しています。
committeeを導入する前は、元々OpenAPIの定義はRailsとは別のリポジトリとして用意していました。実装のコミットとスキーマ定義のコミットは分けた方が見通しがいいだろうと思ってのことです。
しかしcommitteeを導入するとなるとprivateの別リポジトリソースコードを読みに行くのは非常に面倒になってしまうので、上記のメリットだけであるならばRailsリポジトリにOpenAPI定義まで含めた方が楽だろうということで、Railsリポジトリに統合することにしました。運用上困ることは特に起きていません。

committeeを導入することで、rspecのrequest specで assert_response_schema_confirm が使えるようになります。
例えばUser自身のリソースを返すエンドポイントがあったとします。(認証などは除いています)

paths:
  '/users':
    get:
      tags:
        - user
      summary: User自身の情報
      description: 名前とメールアドレスの取得
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSchema'

components:
  schemas:
    UserSchema:
      type: object
      required:
        - user_id
        - family_name
        - given_name
        - email
      properties:
        user_id:
          type: integer
          example: 1
        family_name:
          type: string
          example: 田中
          maxLength: 255
        given_name:
          type: string
          example: 太郎 
          maxLength: 255
        email:
          example: hoge@example.com
          type: string
          maxLength: 255

Rails側で対応するserializerとcontrollerはこんな感じになります。

# app/serializers/user_serializer.rb
class UserSerializer < ApplicationSerializer
  attribute :id, key: :user_id
  attributes :family_name, :given_name, :email
end

# app/controllers/users_controllers.rb
class UsersController < ApplicationController
  def show
    render json: current_user, serializer: UserSerializer
  end
end

request specはこんな感じです。

RSpec.describe 'Users', type: :request do
  describe '#show GET' do
    let(:user) { FactoryBot.create(:user) }

    subject { get(user_v1_users_me_path) }

    before { subject }

    context 'when success' do
      let(:return_http_status) { :ok }

      it 'return expected status' do
        expect(response).to have_http_status return_http_status
      end

      it 'return expected body schema' do
        assert_response_schema_confirm
      end
    end
  end
end

assert_response_schema_confirm を利用することで、OpenAPI定義に準拠しているかをチェックすることができます。
この時点ではテストは通ります。

ここから、例えばUserSchemaに誕生日が追加されたとします。

components:
  schemas:
    UserSchema:
      type: object
      required:
        - userId
        - family_name
        - given_name
        - email
        - birthday
      properties:
        user_id:
          type: integer
          example: 1
        family_name:
          type: string
          example: 田中
          maxLength: 255
        given_name:
          type: string
          example: 太郎 
          maxLength: 255
        email:
          example: hoge@example.com
          type: string
          maxLength: 255
        birthday:
          example: 1990-01-01
          type: string
          format: date

Serializerにbirthdayは定義していないため、このまま再度Request Specを実行するとテストは通らなくなります。

.F

Failures:

  1) Users #show GET when success return expected body schema
     Failure/Error: assert_response_schema_confirm

     Committee::InvalidResponse:
       #/components/schemas/UserApi::UserSchema missing required parameters: birthday
     # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:35:in `rescue in validate_response_params'
     # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:30:in `validate_response_params'
     # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/response_validator.rb:20:in `call'
     # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3.rb:38:in `response_validate'
     # /usr/local/bundle/gems/committee-4.0.0/lib/committee/test/methods.rb:27:in `assert_response_schema_confirm'
     # ./spec/requests/user/v1/users/me_request_spec.rb:32:in `block (4 levels) in <main>'
     # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load'
     # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load'
     # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call'
     # -e:1:in `<main>'
     # ------------------
     # --- Caused by: ---
     # OpenAPIParser::NotExistRequiredKey:
     #   #/components/schemas/UserApi::UserSchema missing required parameters: birthday
     #   /usr/local/bundle/gems/openapi_parser-0.11.2/lib/openapi_parser/schema_validator.rb:62:in `validate_data'

Finished in 1.33 seconds (files took 0.46133 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./spec/requests/users_request_spec.rb:31 # Users #show GET when success return expected body schema

このように、OpenAPI定義に準拠していなかった場合はテストを落とすことができます。導入も簡単で非常に便利です。

ちなみに assert_response_schema_confirm を全てのエンドポイントで記述するのは面倒なので、実際はshared_exampleにまとめています。

# spec/support/response_helper.rb

module ResponseHelper
  shared_examples 'response status & body compare to swagger' do
    it 'return expected status' do
      expect(response).to have_http_status return_http_status
    end

    it 'return expected body schema' do
      assert_response_schema_confirm
    end
  end
end

OpenAPI3のrequiredに注意する

CommitteeによってOpenAPI定義に準拠していることを確認していますが、これはOpenAPI定義でrequiredを設定しなければプロパティの有無の検証ができず、テストが全て通ってしまいます。

components:
  schemas:
    UserSchema:
      type: object
      required:            <- ここに必須とするpropertiesを設定しなければ、テストが全て通ってしまう
        - user_id
        - family_name
        - given_name
        - email
      properties:
        # 省略

type: object を指定する場合は、基本的に併せてrequiredを設定するものと考えたほうが良いかと思います。
その他にもcommitteeが検証してくれる項目は多岐にわたりますが、それぞれOpenAPI定義で指定しなければ検証はされません。(当たり前ですが)

  • 配列のminItemsを指定することで、空配列をバリデーションする
  • リクエストのContent-Type
  • リクエストのパスの存在可否
  • レスポンスのステータスコードの存在可否

Committeeでテストが落ちて欲しいシチュエーションでテストが通ってしまった場合、OpenAPIで定義していないだけということがよくありました。

同様の問題として、OpenAPI定義でよく悩むのはnullableの扱いです。

qiita.com

OpenAPI3になってからはpropertiesに nullable: true フラグを設定することができるようになりましたが、Swagger UIでは表示はまだされません。
Committeeの検証においては、requiredを指定しpropertiesで型を定義すればデフォルトで nullable: false となり、valueがnullの場合はテストが落ちるようになります。
ですので基本的にはキーとバリューが確実に存在する場合はrequiredのみを指定しておき、 valueのnullを許容したいにしたい場合のみ nullable: true , nullable: false は指定しなくてもよい、みたいな運用をしています。

もう一つ、例えばOpenAPIで定義していないcreated_atやupdated_atをレスポンスとして返していたとしても、Committeeは過分に関してはチェックをしてくれません。
スキーマ駆動開発を実践しているならばOpenAPIに定義されているpropertiesがあればクライアントサイドの開発は進められるはずなので、過分があったとしても特に問題はないはずではありますが…

この運用でよかったこと

APIドキュメントの信頼性が高く、保守し続けられることが仕組み化できた

スキーマ駆動開発は何度か経験しているものの、今回のプロジェクトで初めてCommitteeを導入しました。Committeeによってスキーマに準拠していることを検証できるようになったため、APIドキュメントの信頼性がより高められました。

Committeeの導入により、バリデーションを意識してYAMLを書くようになった

requiredやminItemsなどバリデーションを意識して適切なpropertiesを設定するようになり、より詳細なドキュメントが残せるようになりました。

まとめ

今回はスキーマ駆動開発を実際にどのように運用しているのかを紹介しました。今回では紹介しきれませんでしたが、リソース指向のスキーマ定義についてのチームの試行錯誤や、snake_caseからcamelCaseへの変換をコントローラー層で行っている例、コントローラー層での汎用的なエラーハンドリングなど、API開発のTipsを今後も紹介していこうと思っています。
質問や指摘お待ちしております!