OASとTypescript ベネフィット編

4月からRoxxに合流したkotamatsuiです(※kotamatさんとは別人です)。よろしくおねがいします。 こちらの記事はZennからの転載になります zenn.dev


はじめに

スキーマ駆動開発という手法が数年前からたびたび話題にあがっています。 スキーマ駆動開発の紹介は既に多数の先人が記事を書いておられるのでそちらをご覧いただくこととして、この記事ではOAS、特にOpenAPI Genertorを利用したスキーマ駆動開発を導入することによる、フロントエンド目線で見たときに実作業者が得られるベネフィットをまとめてみたいと思います。

実際筆者は最近、APIの仕様書を渡されて2つのエンドポイントからなるSPA(サーバー側はもう動いている状態)を0ベースで開発する機会がありましたが、渡されたAPI仕様書をOAS定義に翻訳し、各種ジェネレーターを駆使してAJAX部分を実装することで、Nuxt typescriptのプロジェクト作成からアプリケーション完成まで4~5時間程度で終わらせることができました。

とにかく使いこなせれば、病みつきになるレベルの爆速開発が可能になります

スタブサーバーが用意できる

OpenAPI generatorには各種スタブサーバー用のジェネレーターが用意されています。 これによりサーバー側の実装が進んでいない状態でも、OASの定義にexampleを書いておけばそのレスポンスを使ってフロント側の実装を進めたり、デモ画面を作成したりすることが可能になります。 フロントエンドエンジニアはexampleだけでもいじれるようになっておけば、様々なデザインパターンを想定したデータを作って開発を進めることができます。

スタブサーバーの実装方法にこだわりが無ければAPI Sproutを利用するのが良いでしょう。 dockerですぐ動く状態のイメージが用意されていますので、docker-composeに追加してOASの定義ファイルをマウントするだけでお手軽にスタブサーバーが用意できます。更新検知もしてくれて、OASの定義を変更すればすぐさまレスポンスも追従してくれます。

API Sproutをローカルの開発サーバーと別のポートで建てておくと、フロント側で開発サーバー見に行くかスタブ見に行くか切り替えるなど出来ていろいろと使い勝手が良いです。

なお、exampleを書かなかった場合でも、例えば文字列なら"String"のような何かしらスキーマに合わせた値を返してくれます。

IDEの強力な支援が受けられる

pathsからAPIクライアントを生成

上記該当箇所のコード

OpenAPI generatorには各種APIクライアント用のジェネレーターも用意されています。

上の図はtypescript-axios用のジェネレーターの例です。ジェネレーターはOASのpathsの内容に応じて上記のようなメンバメソッドを持ったクラスを生成してくれますので、BaseURLや認証情報などのConfigをクラスのコンストラクタに渡してしまえば、あとはメソッドを叩くだけでリクエストが可能です。

youtu.be

上記の例ではリクエストにパラメーターが不要だったので引数がありませんが、パラメーターが必要な場合は引数に何が必要かIDEが表示してくれます。また、Promiseが解決された場合の戻り値の型をAxiosReasponseのGenericsに埋め込んでくれるため、IDEはレスポンスの型を静的に推論することができます。

components/schemas(など)からIntefaceを生成

ジェネレーターは上記APIクライアントと同時にcomponents/schemasの内容やpathsのレスポンスボディなどのスキーマから、TSのInterfaceを生成してくれます。

これによりAPIのレスポンスが返ってくる前の初期値を作ったり、

import { DefaultApi, SomeModel } from "path/to/api";
class Hoge {
  private response: null | SomeModel = null

  async request (): Promise<void> {
    this.response = await new DefaultApi().someRequestGet().data
  }
}

のような形でレスポンスを格納する変数を先にnullで初期化しておくなどの実装も簡単に行うことができます。

この生成されたInterfaceはOASのdescriptionがTSDocの書式で埋め込まれていますので、型をきちんと保ち続けていればマウスホバーするだけでIDEがdescriptionの内容を表示してくれるようになります。このため実装作業者はTSのコードとAPI仕様書やAPIの実際のレスポンスの画面とを行ったり来たりする頻度を大幅に減らすことができます。

また、typescrit-axios用のジェネレーターはenumにも対応しており、x-enum-varnamesというプロパティを使うことでstring enumを生成することも可能です。

~~
components:
  schemas:
    Genre:
      type: string
      enum:
        - rock
        - jazz
        - progress
      x-enum-varnames:
        - rock
        - jazz
        - progress
~~

↓生成物

~~
/**
 * 
 * @export
 * @enum {string}
 */
export enum Genre {
    rock = 'rock',
    jazz = 'jazz',
    progress = 'progress'
}
~~

スキーマ変更したときに影響範囲を一網打尽にできる(実装側の型の健全性が保たれていれば)

これは前項の内容とほぼ被りますが、TSの型検査とスキーマ定義が結合できるということは、スキーマを変更した場合にその影響を受ける部分が型チェックであぶりだせるということになります。

上記の例は、IDはintegerで来ると思って実装していたが実はstringが来ることが分かったのでスキーマを変更したケースです。定義ファイルの該当箇所を変更してジェネレーターでAPIクライアントを生成しなおしてから再度型検査を行ったことで、型が不一致になった個所が画面下のターミナルに羅列されています。

(補)Vueで型健全性をちょっと良くする小技

Vueのtemplate内はただの文字列なので、TSの型検査時には型検査が行われません1

型検査を抜けてしまうということは実際にコンポーネントをマウントして表示させてみるまで壊れていることに気づけないということになり、デバッグの負担が非常に増えます。これを避けるために、computedの戻り値の型指定を使う方法があります。

<template>
  <div>
    {{ hoge.some.property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
}
</script>

例えば上記のような場合、Hogeインターフェースの構造が変わった際にhoge.some.propertyに対する型検査は行われないため実行時エラーになります。 これを下記のように変更すると、トランスパイル時にエラーに気づくことができ、修正の負担を軽減することができます。

<template>
  <div>
    {{ property }}
  </div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'nuxt-property-decorator';

interface Hoge {
  some: {
    property: string;
  };
}
@Component
export default class HogeComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly hoge!: Hoge;
  get property(): string {
    return this.hoge.some.property
  }
}
</script>

template内が簡素になり可読性も上がるため、個人的にはかなり好んで多用しています。 もちろんこの方法はあくまで修正の負担を軽減してくれるだけで、完全に安全であることを保障してくれるわけではありません。テストは別途しっかり用意する必要があります。

実行時に実装とスキーマ定義の不整合を検査できる

スキーマ定義をしっかり決めて開発を進めても、実際に統合テストしてみたらスキーマとずれが生じることは(特に型が怪しい言語を使っている場合)よく起こります。

その際レスポンスやリクエストを目視してどこが不整合を起こしているか確認するというのはあまり賢い選択肢ではありません。OASを利用することでこの不整合カ所の調査を半自動化することができます。

OASopenapi2schemaというツールを使うことでJSON Schemaに変換することが可能です。JSON Schemaはオブジェクトの構造を検証することができるスキーマで、言語に依存しない中立的な仕様としてそれなりに知名度があり、様々な言語に対応しているバリデーターが存在します。

例えばJavascriptであればajvなどがあります。 これらを用いることでリクエストボディを送信する前にOAS定義との整合性を検査したり、レスポンスを受け取った後にレスポンスボディを検査したりすることができます。

ajvによるバリデーションの例

筆者が試した時点のopenapi2schemaはデフォルトでJSON Schema Draft v.4で出力してくれたので、まずはajvに使用するスキーマのバージョンを伝えます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);

次に、このajvと生成したスキーマをセットにしてexportしておきます。

import Ajv from 'ajv';
import draft4 from 'ajv/lib/refs/json-schema-draft-04.json';
import schema from 'path/to/scheme.json'; // openapi2schemaで生成したjsonファイル

const ajv = new Ajv({schemaId: 'auto', allErrors: true}).addMetaSchema(draft4);
const validator: {schema: typeof schema; ajv: Ajv.Ajv} = {
  schema,
  ajv
};

export default validator;

この時一つ注意しておくべきポイントとして、tsconfigの"resolveJsonModule"をtrueにしておく必要があります。"resolveJsonModule"を有効にしておくと、jsonをimportした際にtypescriptが中身を見て自動的に型推論してくれるようになります。 もしプロジェクトの事情により"resolveJsonModule"を有効にできない場合、あまりきれいな方法ではありませんがjson-to-ts-cliを使用してshcema.jsonからinterfaceの定義ファイルを生成することが可能です。

あとは実際にリクエストを飛ばす際に

import validator from "path/to/validator";
import { DefaultAPI } from "path/to/api";

export async function fetch() {
  const api = new DefaultAPI();                  // typescript-axiosクラスを初期化
  const res = await api.someRequestGet();        // リクエストを実行
  const path = "/some/request";
  const validate = validator.ajv.compile(
    validator.schema[path].get.responses[200]
  );                                             // テスト対象のスキーマをajvに流し込む
  const valid = validate(res.data);              // バリデーションを実行
  if (!valid) {
    console.warn(`validation error: ${path}`);
    console.warn(validate.errors);               // バリデーションエラーが発生した箇所をコンソールに表示
  }
}

のようにすることでバリデーションが可能です。

ただ、このスキーマ定義用のjsonはそれなりのファイルサイズになります。本番ビルドにこのjsonファイルを含めるのはあまり賢明ではないと思いますので、適宜ビルド環境に応じてバリデーション関連のファイルとロジックを切り離せるような仕組みにしておくとよいでしょう。

実装と連動した「信頼できる」API仕様書が出来上がる

これまで見てきたようにどっぷりOASに依存してクライアントの開発を進めた場合、必然的にOASスキーマ定義は実装と乖離することができない(乖離してしまうとそもそもまともに動かない)状態に持っていくことができます。

そのため、「OpenAPI generatorが動いている」かつ、「ジェネレーターで生成されたAPIクライアントが実装に組み込まれている」という二点さえ確認できれば、「OASの定義ファイルに書かれている内容は概ね最新の状態に保守されている」ということを保障することができます。

全てのAJAXOASベースに置き換えることができてしまえば、もう二度と「微妙に実装とずれてる仕様書やInterface定義」に苦しめられることはありません。

なお、VScodeにはopenapi-previewというプラグインがありますので、APIドキュメントとして利用するにはこのプラグインyamlファイルだけあれば事足ります。専用のプレビュー用サーバーなどを立てるのは、ほとんどの場合お金と時間の無駄になるだけでしょう。

※ただし、descriptionの文言だけはただの文字列なので、そこに嘘が書かれていてもTypescriptは何も指摘してくれません。信頼できるのはあくまでスキーマです。

まとめ

OASとTSの連携は、一度はまると病みつきになるレベルに爆速でコーディングが可能になるポテンシャルを持っていますが、使ったことがないとどうしても定義ファイルを管理するコストが気になって二の足を踏んでしまうケースが多いかと思います。

OASの定義ファイルと生成物自体は既存の実装を全く破壊しないので、部分的にじわじわとOASに置き換えておくことも可能です。 まずはエンドポイント一つでもOAS化してみて実際にどんなものか体験してもらうのが、ベネフィットを感じるには手っ取り早いかと思います。


  1. VScodeであればVeturのvetur.experimental.templateInterpolationServiceを使うことでtemplate内の型検査も行ってくれますが、今のところエディタ上で警告を出してくれるだけなのでトランスパイルは通ってしまうのと、ものすごくVScodeの負荷が大きくなるので動作が重くなるといった問題があります。