OpenAPIを使ったRailsスキーマ駆動開発

はじめに

前回の記事では、OpenAPIで新しいウェブAPIを定義する際に、yamlのままで読みやすいようにファイル構成等を工夫した話をしました。

今回はそのAPIスキーマを使って、Railsでスキーマ駆動開発を実現するにあたって利用しているツール類についてお話しようと思います。

また、合わせて、その中での利用方法の工夫や、問題となったツールの不具合等についても共有いたします。

サービス構成

前回に引き続き、APIGatewayと教材管理サービスの間、バックエンド内部の通信を対象としています。

APIGateway(クライアント側)と教材管理サービス(サーバー側)は共にバージョン5系のRailsアプリです。1

レポジトリ一覧

主に4つのGitレポジトリを使って開発します。

  • サーバー側Railsアプリ
  • クライアント側Railsアプリ
  • OpenAPIドキュメント
  • openapi-generatorが生成するAPIクライアントgem

それぞれのレポジトリと利用しているツールの構成の概観はこのような感じです。

サーバー側Railsアプリ

サーバーAPIはgrapeを利用して書かれており、レスポンススキーマの整形にactive_model_serializerを利用しています。

サーバー側のGitレポジトリは、OpenAPIドキュメント用レポジトリをGit Submoduleとして持ちます。

クライアント側Railsアプリ

クライアント側は、後述するように、open-api-generetorで生成されたAPIクライアントgemを使ってサーバーにアクセスします。

APIクライアントgemは、通常のgemと同様に、Gemfileを通じて取得されます。

開発の流れ

現在、OpenAPI関連の開発は次のようなステップで進められています。

便宜上、「サーバー側の開発 => クライアント側の開発」という順で書いていますが、 スキーマ駆動開発を採用しているので、両者は並行して進めることが可能です。

  1. OpenAPIドキュメントのレポジトリで、API定義を修正するPRを作成し、レビューの後、masterブランチにマージします。
    • PRが作成されたタイミングのCIで、swagger-cliによるスキーマ定義のバリデーションが行われます。(バリデーションに失敗したらマージ不可)
    • PRがマージされたタイミングのCIで、swagger-cliによって全てのスキーマ定義ファイルが単一のJSONファイルに連結され、作成されたファイルがmasterブランチにコミットされます。
    • PRがマージされたタイミングのCIで、openapi-generatorによってAPIクライアントgemが生成され、別途存在するgem用レポジトリのmasterが更新されます。
  2. (ここからサーバー側の実装)
  3. APIレスポンスのコンポーネント単位のスキーマがOpenAPI定義に沿うように、active_model_serializerのクラスを実装します。
    • 実装したクラスは、json-schemaのgemを使ったテストコードでチェックされます。
  4. active_model_serializerのクラスを使って、APIエンドポイントを実装します。
    • 実装したエンドポイントは、committee-railsを使ったテストコードでチェックされます。
  5. (ここまでサーバー側の実装) (ここからクライアント側の実装)
  6. openapi-generatorで生成されたAPIクライアントgemのモデルクラスに対して、APIスタブ用のfactory_botを定義します。
    • 出力がOpenAPI定義に反していないことを確認するために、factoryに対するテストコードを書いています。検証にはAPIクライアントgemのモデルクラスの機能を利用しています。
  7. その他必要なクライアント側の実装を行います。
    • サーバーへのアクセスには、openapi-generatorの生成したAPIクライアントgemを利用します。
    • クライアント側のテストコードでは、上記のfactoryを使ってAPIレスポンスのスタブを組み立てます。
  8. (ここまでクライアント側の実装)

利用ツール

swagger-cli

github.com

OpenAPI関連のツールは、ものによってyaml形式やファイル分割への対応状況がまちまちなので、どんなツールでも利用可能な単一のJSONファイルの形式に変換するために採用しました。

また、スキーマ定義がOpenAPI仕様に沿って書かれているかのバリデーション機能も備えているので、それも合わせて利用しています。 他のツールに比較して、間違っている箇所が把握しやすいエラーメッセージが出力されるように感じます。

公式のdockerイメージが配布されており、これを利用することで上記のような各タイミングでCIで自動実行することが簡単にできます。

committee-rails

github.com

スキーマ駆動開発の実現のため、可能ならばサーバー側もコードの自動生成を利用したかったのですが、Rails対応で良い感じのものが見つかりませんでした。 そこで、Rails + OpenAPIで広く行われているように、自動テストにCommitteeを用いたスキーマチェックを導入する形でサーバーの実装がOpenAPI定義に沿っていることを担保するようにしました。

前述の通り、サーバー側RailsアプリはOpenAPI定義ファイルのレポジトリをGit Submoduleとして持っています。 そして、次のようなモジュールを作り、スキーマチェックを行う各specファイルでincludeすることで、自動でファイルパスなどの設定が行われるようにしています。

module CommitteeHelper
  def committee_options
    @committee_options ||= {
      schema_path: Rails.root.join("openapi_submodule/path/to/openapi.json").to_s,
      query_hash_key: "rack.request.query_hash",
      parse_response_by_content_type: false,
      prefix: "/api/prefix",
    }
  end
end

スキーマ定義のパース周り2でOpenAPI仕様をカバーしていない部分も多く、次のような不具合に悩まされました。

不具合1: ファイルの分割

hachi.hatenablog.com

こちらのブログで報告されているように、分割したファイルへの$ref参照周りにバグがあります。

同様の問題がgithubのissueで報告されており、こちらでも、上記のブログ記事のように、参照先のファイルをpathscomponentsのようなトップレベルから書くことで解決できるとされています。 しかし、それだとファイルを分割したメリットがだいぶ失われてしまうので、今回は前述のように、swagger-cliで連結したJSON形式の定義ファイルを用いることでテスト時にファイル結合を行う必要がないようにしています。

不具合2: $refとnullableの同時使用

$refで参照したスキーマを参照元でnullableにする方法について、OpenAPI仕様では明示的な定められておらず、実現には一工夫が必要となります。

例えば、多くのツールにとっての対応済みの最新バージョンであるOpenAPI 3.0の記法では、type: nullのスキーマ定義が認められていません。3 また、$refディレクティブは、他の並立するほとんどを上書きしてしまうので、OpenAPI定義の記法であるnullable: trueと同時に利用しても、nullは許容されません。

# OpenAPI 3.0では不可
some_property:
  allOf:
    - type: null
    - $ref: path/to/reference.yml#schema_name

# nullableにならない
some_property:
  $ref: path/to/reference.yml#schema_name
  nullable: true

OpenAPI 3.0で利用可能な記法としては次のように、参照先をallOfで包んでnullableディレクティブを上書きさせない記法と、type: nullを使わずにnullしか設定できないスキーマを定義してanyOfで並列させる記法の、大きく2つがあります。4

# allOfで包む記法
# Committeeではnullableとして扱ってくれない
some_property:
  nullable: true
  allOf:
    - $ref: path/to/reference.yml#schema_name

# anyOfで並べる記法
some_property:
  nullable: true
  anyOf:
    - $ref: path/to/reference.yml#schema_name
    - type: string
      nullable: true
      enum: [null]

しかし、Committeeは前者のallOfで包む記法ではnullを許可してくれません。 利用するツールがCommitteeだけであれば、後者の方法を使えば良いだけの話ですが、OpenAPIGeneratorには後述のanyOfに関するバグがあったので、ダブルバインドに陥ってしまいました。

json-schema

github.com

レスポンスのスキーマチェックは前述のCommitteeで一通りできるのですが、せっかくコンポーネント構成を工夫したAPI定義をしたので、コンポーネント単位でもスキーマチェックができるようにしています。 具体的には、前述の開発の流れにあるように、OpenAPIで定義されているコンポーネントの単位でactive_model_serializerのクラスが実装されるので、それに対するユニットテストでOpenAPI定義を利用できるようにします。 狭いスコープのテストでスキーマチェックを行うことによって、例えばnil/非nilの場合わけや各enum値が全て正しく出力されるかなど、細かい条件での動作確認がやりやすくなっています。

Committeeをはじめとした一般的なOpenAPI検証ツールでは、スキーマチェックはレスポンス単位で行われ、コンポーネント単位のチェックはできないので、通常のJSON Schemaの検証用gemであるjson-schemaを使ってスキーマ定義と実際の出力の照合を行います。 また、OpenAPI定義のルートにcomponentディレクティブを置かなかったために結合されたJSON形式の定義ファイルから目的のコンポーネントを見つけ出すのが難しかったので、こちらの検証ではJSONファイルではなく、結合前のYAML形式のコンポーネント定義ファイルを直接参照しています。

openapiの記法に合わせた機能拡張

OpenAPIに置けるスキーマ定義の記法には、一部一般的なJSON Schemaの記法とは異なるところがあるので、その差異を吸収するために、gemで推奨されている方法で、次の2つの点で、スキーマ定義クラスを拡張しています。

  • nullable: trueの形式でnull許容を設定可能にする
  • $refで参照先を指定するときに、参照元ファイルからの相対パスで指定できるようにする

ただし後者については、参照元ファイルのファイルパスを動的に取得する手段が見つからなかったので、「コンポーネント定義ファイルは全てcomponents/ディレクトリ以下にフラットに配置される」というローカルな定義ルールを利用して、ちょっと強引に実現させました。

拡張したスキーマ定義はCommitteeと同様に専用モジュールを用意し、assert_valid_json_schemaという独自のアサーションメソッドを使って簡単にテストが書けるようにしています。

module OpenAPIComponentHelper

  class CustomizedSchema << JSON::Schema::Draft3
    # 元のgemから拡張したクラスの定義は省略
  end

  # draft3指定時にvalidationに機能を拡張したクラスを利用するようにセットする
  JSON::Validator.register_validator CustomizedSchema.new

  def assert_valid_json_schema(target_json, schema_file_name, component_name)
    schema = YAML.load_file "#{COMPONENT_DIR}/#{schema_file_name}"
    begin
      # OpenAPIGeneratorが対応しているnullableと`$ref`の同時使用時の記法の関係で、draft3のバージョンを指定する必要がある
      JSON::Validator.validate! schema, target_json, strict: true, fragment: "#/#{component_name}", version: :draft3
    rescue JSON::Schema::ValidationError => e
      # 失敗したデータの中身を表示するため、一度キャッチしてエラーメッセージを組み立て直す
      raise "Failed to JSON schema validation: #{e.message}\ngiven data:: #{target_json}"
    end
  end
end

openapi-generator-cli (Ruby client)

github.com

APIクライアントの実装は、OpenapiGeneratorが生成するRubyクライアントが本番で利用可能な水準だったので、それを使っています。

生成されるコードは.gemspec形式のファイルを含めたgemの形式で出力されるため、OpenAPI定義を管理する場所とは別に新しくGitレポジトリを作り、通常のgemと同じようにGemfileで指定する形で各アプリケーションで利用できるようにしました。 そして、開発の流れの通り、gemレポジトリの更新はCIによって自動化されており、特に意識して管理する必要がないようになっています。

機能面ではオープンタイムアウトの設定と結果のキャッシュ関連が求めている内容に対して不足していたので、アンチパターンになりますが、生成されたクライアントクラスを継承を使って元クラスの実装の詳細に介入する形で拡張しています。 また、生成されたクライアントを使うにあたっては、次のような不具合に悩まされました。

不具合1: 中途半端な型チェック

生成されたRubyクライアントは型チェック機能が備わっており、サーバーからのレスポンスがスキーマ定義に沿っていない場合には例外が発生するようになっています。 しかし、型チェックの実装箇所が少し特殊で、モデルクラス単体で利用したときに型チェックの恩恵を十分に受けるには実装方法に少々工夫が必要となっています。

レスポンスのモデルクラスのインスタンスに値を設定する手段としては、「1. コンストラクタの引数で設定する」「2. attr_accessorで生成されるセッターメソッドを使う」「3. build_from_hashというクラスメソッドを使う」という3つの方法が用意されているのですが、クラスメソッドを使う3番目の方法以外では、セットされる値の検証をしてくれないのです。

値をセットした後に検証するためのvalid?のようなメソッドも用意されていますが、nullや最大最小のチェックしかやってくれず、例えばオブジェクト型のプロパティに文字列がセットされていても異常を検知してくれません。

APIサーバーからのレスポンスをパースする際にはbuild_from_hashというクラスメソッドが使われており、これをつかって値をセットしたときにしか、型やenumの値の正しさが検証できないようになっています。

不具合2: oneOf/anyOfに非対応

anyOfoneOfを使ったスキーマ定義すると、AnyOfXXXYYYOneOfXXXYYYのような名前の存在しないクラスを参照するコードが生成されてします。 XXXYYYanyOfoneOfで組み合わされた型に応じたクラス名なので、対応するクラス定義を生成するコードが未実装なのでしょうか。

anyOfが使えないため、前述の方法でCommitteeに$refの参照先をnull許容させることができず、結果的に$refの参照先をnullableにすること自体を諦める結果になりました。

factory_bot

github.com

一般的には、ActiveRecordのモデルクラスに対してテスト用のデータを生成するために使用されますが、ここではopenapi-generatorが生成したAPIレスポンスのモデルクラスに対して使用し、アプリケーション内部での自動テストのためのデータの生成にも使用しています。

前述の通りopenapi-generatorが生成するモデルクラスは型のサポートが不十分で、普通にfactoryを定義するだけだとOpenAPI定義の更新への追随を忘れ、facotoryと実際のデータに乖離が発生するおそれがあるので、変則的ですが、factoryに対しても下のようなRSpecのテストを書いて、factoryのデフォルトがOpenAPI定義に沿っていることを確認できるようにしています。

# factoriesはFactoryBot::Factoryクラスの配列
factories.each do |f|
  describe f.name do
    it "デフォルトでvalidなオブジェクトが作られる" do
      # build_from_hashはenumの値が不正な場合に例外が発生する
      # FactoryBot.buildでは型チェックが行われないためbuildclass.build_from_hashを使用する
      obj = f.build_class.build_from_hash(build(f.name).to_hash)
      expect(obj.list_invalid_properties).to be_empty
    end

    f.defined_traits.map(&:name).each do |trait|
      context "traitが#{trait}の場合" do
        it "validなオブジェクトが作られる" do
          obj = f.build_class.build_from_hash(build(f.name, trait).to_hash)
          expect(obj.list_invalid_properties).to be_empty
        end
      end
    end
  end
end

終わりに

OpenAPIを用いたスキーマ駆動開発に挑戦してみて、gRPC・GraphQLという他技術と比べたときのエコシステムの弱さを正直感じました。

元はAPI仕様の記述を目的として作られた背景があるためか、ドキュメント生成関連の充実ぶりに比べて、コード自動生成関連のツールが少なかったです。 サーバーのコード生成についても、スタブとして開発中に一時的に利用するためのものはそれなりにある一方で、定義修正と実装のサイクルを繰り返せて行けそうなツールはほとんど見つかりませんでした。5

開発主体についても、OpenAPIのスキーマ仕様については大きなコミュニティが存在しているものの、ツールの開発はほとんどが個人レベルに任されており、GoogleやMetaのような巨大企業が開発したgRPC・GraphQLと比べると保守体制が弱いものが多いように感じます。 最新バージョンのOpenAPI 3.1が公開されて1年以上経つのにそれをサポートしているツールがほとんどない点と、利用するツールよって定義の書き方の細かい部分を調整する必要が出てくる点が特に辛いところです。

しかし、JSON形式のレスポンスを用いるシンプルさと、サーバー側・クライアント側共に既存の実装を大きく変えることなく導入できる点は、gRPCやGraphQLには真似できない大きな強味なので、OpenAPI関連のエコシステムがもっと発展し、スキーマ駆動開発を導入への障壁が下がって広く導入されていって欲しいと思います。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。

カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com


  1. 5系のバージョンはサポート期間を過ぎてしまっているので、両アプリを年内にバージョン6.1まで上げられるように鋭意アップデート作業中です!!

  2. CommitteeのOpenAPI定義のパース周りの実装は、open_api parser

  3. 最新のOpenAPI 3.1ではtype: nullの利用が可能となりましたが、3.1に対応済みのツールがまだほとんどないのが現状です。

  4. 参考: https://stackoverflow.com/questions/40920441/how-to-specify-a-property-can-be-null-or-a-reference-with-swagger

  5. 別言語になりますが、Goのopenapi-codegenというツールは、インターフェース定義を中心に生成される形になっており、欲しかったものに近いと感じました。