APIドキュメントを実装と乖離させないために

内部用APIであるか外部の開発者向けのAPIであるかに関わらず、ドキュメントと実装との乖離は極力避けたいものであるが、注意深く開発を進めない限りこの状況は容易に起こり得る。何が乖離を引き起こし、どうすればこの状況を回避できるのか考えながら、JSON Schemaの利用例を紹介する。なおこの投稿では、HTTP経由でデータの通信を行うような狭義のAPIのことをAPIと呼ぶことにする。

同じ情報源を参照する

APIドキュメントと実装が同じ情報源を参照するようにすれば、論理的に関連した要素は統一的に変更され、これらの変更は完全に同期が取れたものになる。つまり、変更時に乖離が生じにくくなる。但し情報の見せ方によって乖離が発生する可能性は十分にだろうし、乖離が発生するのは理解しようとする側の認識の問題であるから、論理的に全く起こり得ないということではない。

この参照の形には、両者が別の情報源を参照するという形や、実装をもとにドキュメントが生成される形、ドキュメントをもとに実装か生成されるという形が考えられる。現実の例で言うと、例えば実装における型定義からドキュメントを生成したり、インターフェース仕様書からドキュメントと実装の両方が生成される、という具合である。

型定義  ----> ドキュメント

I/F定義 ----> ドキュメント
        `---> 実装

例えばJSON Schemaを使う

インターフェース仕様書を元に実装やドキュメントを生成する例として、JSON Schemaを使ってREST APIのドキュメントを生成しながら開発を行う方法を紹介する。今回は説明のしやすさからJSON Schemaを利用するが、インターフェース定義を表現できる別のツールでも同様の処理を実現できるに違いない。以下は、JSON Schemaに沿ったデータ構造をYAMLで表現したものである。

properties:
  user:
    properties:
      name:
        type: string
        pattern: "^[a-z0-9]{3,20}$"
    links:
      - method: GET
        href: "/users"
        rel: instances
        schema:
          properties:
            page:
              description: 1から始まるページ番号だよ
              type: string
              pattern: "^[0-9]+$"

JSON Schemaは基本的にはJSONで表現するものだが、JSON Schemaに対応した各種ライブラリに与える場合には結局適当なデータ構造にデコードすることになるので、同等なデータ構造を表現できるのであればYAMLを利用しても特に問題はない。プログラム間でデータを受け渡す場合にはJSONを利用すると便利だが、人間がエディタで編集する場合にはYAMLなどの行指向のデータ形式を利用した方が寧ろ便利なことが多い。

上記のJSON Schemaでは、トップレベルにuserというプロパティが存在すること、userはnameという3文字から20文字の文字列のプロパティを持つこと、userに関連する(=レスポンスとしてuserを返す)リンクとして GET /users というリンクがあること、このリンクはpageという1から始まるページ番号を文字列で受け取ること、といった情報を表現している。

インターフェース仕様からドキュメントを生成する

抽象的なインターフェース仕様としてJSON Schemaを記述したが、人間がこれを読んで理解するのは最高の状態とは言えないので、より人間の読みやすい形式 (つまりドキュメント) に変換する必要がある。ここでは、jdocを利用してJSON Schemaを表現するデータ構造をMarkdownに変換してみる。

jdoc schema.yml > docs.md

これでMarkdownが出来上がるので、そのまま見るのも良いし、レポジトリに含めておいてGitHubを通して見るのも良い。ただschema.ymlだけ更新してドキュメントが更新されていないということは避けたいので、schema.ymlの変換結果とdocs.mdの内容が同じであることを保証するテストを書いておいてCIで検知する、といった運用をするのが良いかもしれない。また、サイトでAPIドキュメントを提供するような場合、Markdownをファイルに書き出すのではなく、直接HTMLを描画するのに使うこともできる。

schema = YAML.load_file("schema.yml")
html = Jdoc::Generator.call(schema, html: true)

インターフェース仕様を実装に利用する

インターフェース仕様とドキュメントとの整合性がとれても、インターフェース仕様と実装が同期していなければ、結局ドキュメントと実装が乖離してしまう。そこで、インターフェース仕様と実装を同期させるために、インターフェース仕様で定義している内容を実装の中でも利用する方法を考えたい。JSON Schemaで定義される内容をAPI利用者目線で整理すると、以下のような仕様が読み取れる。

  1. GET /users というAPIが存在すること
  2. GET /users に入力できるデータの形式
  3. GET /users で出力されるデータの形式

Rack::JsonSchema::RequestValidationというRack middlewareを利用することで、JSON Schemaを利用してサーバに入力されるHTTPリクエストを検閲できる。これを利用すると、定義されていないAPIに対してリクエストが送られた場合や、定義されたものと異なるデータが入力された場合に、エラーレスポンスを返せるようになる。つまり、上記の1と2の仕様に対して、インターフェース定義をもとに対応する実装が自動的に定義されることになる。

use Rack::JsonSchema::RequestValidation, schema: schema

インターフェース仕様をテストに利用する

出力されるデータの形式についても、JSON Schemaを利用してレスポンスを検閲するRack::JsonSchema::ResponseValidationというRack middlewareが利用できる。これは、返却しようとしているレスポンスに含まれるデータがインターフェース定義に反するものであった場合に例外を発生させるというものである。つまり、宣言した通りの実装を行っているかをチェックできる。

本番環境で利用するとユーザにエラーレスポンスが返ってしまうため、これはテスト環境で利用するのが良いだろう。もし誤った実装をしていた場合、正常系のテストが書いてあれば例外が発生してテストが失敗するため、本番環境に反映させる前にテストを実行することで誤りを検出できる。

use Rack::JsonSchema::ResponseValidation, schema: schema if ENV["RACK_ENV"] == "test"

Rack::JsonSchemaを使った開発の流れ

既存のアプリに導入する場合には、最初に以下の作業が必要になる。

  1. とりあえず空のJSON Schemaを置く
  2. Rack::JsonSchemaをmiddleware stackに入れる
  3. テストが失敗する
  4. JSON Schemaを編集する
  5. テストが成功する

新しいAPIを追加したり既存のAPIに変更を加える場合には、以下の作業を行うことになる。

  1. JSON Schemaを編集する
  2. テストを追加する
  3. テストが失敗する
  4. APIを実装する
  5. テストが成功する

JSON Schemaに対する私見

JSON Schema (とその周辺ツール) を利用せずとも同等の成果物を得ることは可能だが、利用する方がより短い時間で実現できるため利用している。既存の問題を幾つか解決し、また新たな問題を生むが、既存の問題を残しておくよりも状況は改善している。同じ問題を解決する他のツールに比べ、新たに生まれる問題の規模が比較的軽微だと思っている。

現在のところ、自分はJSON Schemaを実装のための道具としてしか利用しておらず、外部の開発者やプログラムとやり取りするためのプロトコルとして積極的に利用している訳ではない。例えばJSON Schema自体を (Herokuのように) 公開インターフェースとして提供するとか、それを元にクライアントライブラリを生成するとか、やり方は色々あるが、この辺りはまだ実験段階だと言える。

JSON Schemaの使い方で気に入っているところは、リソースベースでのAPIの構造化を促進させる点。本来は全てのAPIについてそれぞれ入力と出力のインターフェースを定義しなければならないところだが、リソースごとに関連するAPIをまとめることで、出力に関するインターフェース定義の重複を排除している (同じリソースに対するAPIは同じ出力を返すという規則性から)。

JSON Schemaは最高のツールではないが、利用に支障を来すほどの大きな問題があるというわけではないし、それに解析器は自分で実装できるほどには単純である。現代のREST APIに対する温度感や、RailsやRackを利用した開発方法との親和性、それから技術の流行り廃りの速度を考えると、少ない労力でそこそこの成果を出すのにそこまで悪くない道具だろうと思う。現代のAPI開発というコンテキストにおいて、何年もの間安定して使えるような仕組みを望むのはなかなか効率が悪い。技術の変遷が古い仕組みを押し流していくのだから、流れに抗って必死に耐えるのではなく、流されながらも少しずつ進行方向を調整していくという方がよほど現実的だと考えている。

追記: なぜJSON Schemaか

プログラムから扱いやすいから (Ruby用のパーサの実装が便利