弊社でWebアプリケーションを制作する場合、フロントエンドとバックエンドは別々に開発することが多いです。
フロントはほぼNuxt.jsですが、APIは誰が主導するかや求められるスピード感などでプロジェクトごとに変わってきます。
私が主導するプロジェクトである程度スピード感も求められる場合は主にRuby On Railsで開発をしています。(以下Railsで開発するという前提で話を進めます)


前提

課題感

フロントエンドとバックエンドのプロジェクトが別々かどうかはあまり問題ではありませんが、APIの実装を担当する人とフロントエンドでAPIの組み込みを担当する人が異なる場合はAPI仕様の認識合わせが一つ大きな問題となります。

組み込みを担当する人がAPI側のコードを読める人であったとしても、組み込みのたびにコードを読み解くことはしたいと思わないでしょうし、APIの実装を担当する人も新しいエンドポイントの追加やメンバーの参加のたびに説明に時間は割きたくないでしょう。

一般的な解決策としてはAPI仕様をドキュメント化することが考えられますが、ただドキュメント化するという考えだけではなかなかうまくいかないのではないでしょうか。

OpenAPIの活用

弊社ではAPI仕様のドキュメント化に OpenAPI を活用しています。

DSLなどからOpenAPI(またはSwagger)の定義ファイルを出力するライブラリもありますが、対応するバージョンがライブラリに依存してしまうことなどもあり個人的にはYAMLで直接書く方法をとっています。

API仕様を記述したYAMLファイルはリポジトリにPushした時点でCircleCIのArtifactsとしてSwagger UIを展開し、プロジェクトの参加者が閲覧できるようにしています。(どこに展開したとしてもこれを見てもらう意識付けは必要ですが😅)

それでもドキュメントを更新するのを忘れてしまった、ドキュメントを更新したけど実装と違ってしまっていた、などの問題は起こりえます。


スキーマファーストな開発

API仕様のドキュメントを書くタイミングがいつであれ、「ドキュメントを書く」という作業は地味ですし実装との関連性がないとどうしても気が重くなります。

なにかいい方法はないかと探していたところ、OpenAPIのスキーマをもとにAPIレスポンスのテストを書くことができる committee というgemを紹介する記事があり、使ってみたところなかなか良さそうだったので簡単に使い方の紹介とどう活用できるかを考えてみました。

committeeを使ったテストの例

細かいセットアップはここでは割愛しますが、例えば以下のようなAPI仕様を定義したとします。

openapi: 3.0.2

info:
  title: example
  version: '1'

servers:
  - url: http://localhost:3000/{api_version}
    description: local server
    variables:
      api_version:
        default: 'v1'
        enum:
          - 'v1'

components:
  schemas:
    User:
      type: object
      description: user
      required: [id, email, first_name, last_name]
      properties:
        id:
          type: integer
          description: user id
        email:
          type: string
          format: email
          description: email address
        first_name:
          type: string
          description: first name
        last_name:
          type: string
          description: last name

paths:
  /users/{user_id}:
    get:
      tags: [user]
      parameters:
        - in: path
          name: user_id
          description: user id
          required: true
          schema:
            type: integer
      responses:
        200:
          description: ok
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

この例ではバージョニングできるようパスを切っていますが、設定は以下のように prefix を追加することで対応可能です。committee-railsというgemを使うとセットアップが簡単にできます。

  config.include Committee::Rails::Test::Methods
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('doc', 'openapi.yml').to_s,
    prefix: '/v1'
  }

GET /users/{user_id} でユーザー情報を取得するAPIという想定なので、これをRSpecとcommitteeで検証するコードは以下のようになります。

RSpec.describe 'user api', type: :request do
  let(:user) { create(:user) }

  describe 'GET /users/:id' do
    it 'success' do
      get user_path(user.id)
      expect(response).to have_http_status(:ok)
      assert_response_schema_confirm
    end
  end
end

このテストでOpenAPIで記述した仕様と実際のレスポンスが合致しているかどうかを検証できるようになりました。(committee-railsのREADMEには assert_schema_conform を使った例が記載されていますが、deprecationの警告が出ていたので assert_response_schema_confirm を使っています)

スキーマを書くことから始める

OpenAPIで記述した仕様をもとにテストを書くことができるようになったわけですが、これを実際の開発フローでどう活用すると良いか考えてみると、

  1. APIの仕様を検討
  2. 仕様が決まったらOpenAPIに則ってドキュメントを書く
  3. ドキュメントをもとにしたテストを書く
  4. API実装
  5. 組み込みへパス

というのがいいのかなと思いました。

進め方としてはTDDと言えると思いますが、ポイントとしては1, 2の段階でバックエンドとフロントエンドでAPIの仕様について認識を合わせた上でベースとなるドキュメントを作ることかと思います。

一番最初のステップでスキーマが共通認識のもとにできていて、APIはそれを満たす形で実装されることが保障されれば、認識の齟齬による手戻りを減らすことができるのではないでしょうか。

というのを一人で考えていたので、チームで活用していけるかはこれから試していきたいと思っています。

→スキーマからクライアントを生成して組み込む方法はこちらで紹介しています。


余談:committeeで気になったところ

プロパティの検証

Userのスキーマ定義で required: [id, email, first_name, last_name] としていますが、requiredとしないプロパティの有無は検証できません。(明らかに違うタイプを返すなどは検証できます)
仕様として違和感があるわけではないですが、これはプロパティの追加時に記述を忘れてしまうことがありそうだなと思いました。

また、仕様には記載していない created_atupdated_at がレスポンスに含まれていてもテストは通ってしまいます。

仕様に記述したプロパティが過不足ないかを簡単に検証できると良いなと思ったので、ドキュメントなどみつつ考えてみようかと思います。

ファイルの参照が解釈されない

OpenAPIやSwaggerのファイルを記述していくとよくあるつらい点は、内容に対してファイルが肥大化しやすいことだと思います。(エンドポイントはそんなにないのに数百行とか😇)

YAMLやJSONを独自ルールで分割して結合するタスクを実行するというのはよく見る方法ですが、OpenAPIのバージョン3ではファイルの参照が可能になっています。

paths:
  /users:
    $ref: '../resources/users.yaml'
  /users/{userId}:
    $ref: '../resources/users-by-id.yaml'

このように分割したファイルの参照がサポートされているので、追加で結合する処理を走らせたりする必要はありません。

しかし、committeeではファイルの参照が正しく解釈されないようでスキーマの検証には使えませんでした。

やりようはあるのかもしれませんが、結局の所一つのファイルに書き出してそれをcommitteeから参照することになりそうです。

→スキーマを分割して管理する方法についてこちらに書きました。