asterisc

技術ネタ中心にその他雑多なこと

agreed-typedの紹介

はじめに

agreed-typedというツールを現在開発中なので、その紹介をします。

github.com

自分は普段バックエンドを書いているエンジニアで、フロントエンドエンジニア向けのツールであるagreedをメインで活用することはないのですが、APIドキュメンテーションや、バックエンドとフロントエンドのAPIの整合性をとるという観点から、agreedを補完するためのツールとしてagreed-typedを開発しています。 その背景と、agreed-typedで実現したことを紹介していきます。

以下のサンプルコードはすべてGithubにupしてあります。

github.com

agreedとはなにか

agreed-typedの説明をする前に、agreedの説明をします。

agreedの紹介

agreedリクルートテクノロジーズがOSSとして公開している、Consumer Driven ContractおよびJSON Mock Serverのツールです。agreedの詳しい紹介については、この記事をみていただきたいのですが、簡単にまとめると、APIの仕様定義をバックエンド側(API提供側)が決めるのではなく、フロントエンド(API使用側)が主導する Consumer Driven Contract を達成するためのツールで、フロントエンドエンジニアがまずjsやjsonyamlなどでAPI定義を記述し、バックエンド側がそれを実装するといった開発フローを実現します。

実際の開発フローを以下に記載していきます。

1. agreed fileの記述

フロントエンドエンジニアが、画面の描画に必要なAPIjson(5), yaml, jsなどで記述する。(これをagreed fileといいます) 例えば、 GET /user/:id にリクエストを行い、レスポンスとして、 {message: "hello ${id}"} (この id はURLの :id に相当する) といったjsonを期待する場合、以下のように書きます。

// save as agreed.js
module.exports = [
  {
    request: {
      path: '/user/:id',
      method: 'GET',
    },
    response: {
      body: {
        message: 'hello {:id}'
      }
    }
  }
}

これはjsでagreed fileを記述する場合ですが、普段jsを書いているエンジニアならば比較的直感的に書ける形式かと思います。これをPull Requestなどのコミュニケーションパスを用いて、バックエンドエンジニア側に伝えます。バックエンドエンジニアはこれを用いて、このAPI定義の通りのリクエストレスポンスになるように実装し、フロントエンドエンジニアは、バックエンドから提供されるAPIがこの通りのレスポンスを返してくれるものと想定して機能・画面の実装を開始します。

2. フロントエンド開発のためのMock Server

フロントエンドエンジニアは、記載したagreed fileを用いて、JSON Mock Serverを立ち上げることができます。 npmなどを用いて、agreedをインストールします。

$ npm install -g agreed

agreedをinstallすると、agreed-serverというコマンドがインストールされます。 --pathoptionで、記述したagreed fileのpathを渡すと、JSONのMock Serverが立ち上がります。

$ agreed-server --path ./agreed.js --port 3010

試しにcurlでリクエストを行います。

$ curl -XGET localhost:3000/user/123
{"message":"hello 123"}

期待通りにURLのパラメータで渡したidがセットされたresponseが返って来ました。 フロントエンドエンジニアは、APIの実装完了を待たずして、画面や機能を開発することができます。

3. バックエンド開発のためのAPI Checker

JSON Mock Server以外にも、バックエンドのために期待通りのレスポンスが返せているかをチェックする機能があります。 agreedをインストールすると、agreed-serverに加え、agreed-clientというコマンドがインストールされます。このagreed-clientを使うと、実装したAPIがagreed fileの定義に沿っているかをチェックすることができます。 今回はデモ用にexpressを用いて以下のような簡単なAPIサーバを用意します。

const express = require('express');
const app = express();

app.get('/user/:id', (req, res) => {
  res.json({message: 'hello'})
});

app.listen(3010, () => {
  console.log('listen');
});

このサーバはGET /user/:idのpathにリクエストを飛ばすと、固定で{message: "hello"}というレスポンスを返します。つまり、agreedで定義されたAPIとは異なる挙動をします。 このAPIに対して、agreed-clientでチェックをします。

$ agreed-client --path ./agreed.js --port 3010 --host localhost
✗ fail! GET /user/:id
body:  { message: 'hello' }
message
mismatch value, agreed value is hello {:id}, but actual value is hello
agreed:  hello {:id}
actual:  hello

agreedで定義された挙動と異なるので、failしました。このように、APIが正しく実装されてるのかのcheckingを行うことができます。 API側を修正します。app.getの中身を修正すれば良さそうです。

 app.get('/user/:id', (req, res) => {
-  res.json({message: 'hello'})
+  res.json({message: `hello ${req.param.id}`})
 });

再びagreed-clientでチェックします。

$ agreed-client --path ./agreed.js --port 3010 --host localhost
✔ pass! GET /user/:id

今度はpassしました。APIが正しく実装されたことがわかります。

このように、agreedは 1. フロントエンドエンジニアフレンドリーなI/Fにより、フロントエンド主導のAPI開発を実現する。 2. Mock Serverによりフロントエンドとバックエンドの開発を非同期的に行うことが可能になる。 3. API Clientにより、APIのE2Eテストを可能にする。

といった効果をもたらします。

agreedのユースケース

上記で紹介したのは最もシンプルなケースでした。ここでは実際の開発現場での使われ方を少し紹介します。

正常系・異常系の出し分け

極端に単純なAPIでない限り、APIにはエラーのケースがあります。agreedはこれらの表現も可能です。 先程作ったGET /user/:idAPIに拡張して、異常系を追加しようと思います。

module.exports = [
  {
    request: {
      path: '/user/:id',
      method: 'GET',
    },
    response: {
      body: {
        message: 'hello {:id}'
      }
    }
  },
+ {
+   request: {
+     path: '/user/9999',
+     method: 'GET',
+   },
+   response: {
+     status: 404,
+     body: {
+       message: 'user not found'
+     }
+   }
+ }
]

agreedはrequest / responseが対になったオブジェクトの配列として定義されます。 今回は、上記のAPIに加え、GET /user/9999にアクセスしたときに、404になるようなAPIとして定義します。リクエストのpath, methodは最初に定義したAPIと同一ですが、存在しないユーザidにアクセスするパターンを想定しています。

mock serverを立ち上げ、curlで動作をチェックします。

$ agreed-server --path ./agreed.js --port 3010
$ curl -XGET localhost:3010/user/9999
{"message":"user not found"} # not foundのケース

$ curl -XGET localhost:3010/user/123
{"message":"hello 123"} # 正常系のケース

URLに与えるidの値により、responseが動的に出し分けられることが確認できます。agreedでは、pathやrequest bodyの値によるresponseの出し分けが可能となっています。

agreedの運用上の課題

agreedはフロントエンド・バックエンドの初期開発には非常に効果的なのですが、運用を続けていくにつれ、いくつかの課題が見られました。以下で紹介します。

agreedの記述力の問題

上記の異常系の出し分けのパターンで見たように、agreedは同一path, 同一メソッドのAPI定義を書くことも許容できます。したがって、以下のようなパターンの記述も可能です。

module.exports = [
  {
    request: {
      path: '/user/',
      method: 'POST',
      body: {
        email: '{:email}',
        name: '{:name}'
      }
    },
    response: {
      status: 201
      body: {
        id: 1,
        message: 'created!'
      }
    }
  },
  {
    request: {
      path: '/user/',
      method: 'POST',
      body: {
        email: '{:email}',
        name: '{:name}',
        phoneNumber: '012-345-678' // <- 必須パラメータ???
      }
    },
    response: {
      status: 201,
      body: {
        id: 1,
        message: 'created!'
      }
    }
  }
]

このAPI定義では、同一pathに対してPOSTを送っているAPIの定義になります。1つ目の定義ではphoneNumberというプロパティがありませんが、2つ目の定義には存在しています。この場合、phoneNumberというプロパティをどう解釈すればよいのか、ここからだと判断する方法がありません。実は必須パラメータで、1つ目の定義は書き忘れただけなのか、任意のパラメータなのかといった解釈の余地があり、これだけでAPIを実装することはできません。 このように、agreedは柔軟な記述能力を持っている反面、strictなAPI定義ができないという弱点を持っています。

API documentationの二重管理

上記の記述能力の問題にも関わってくるのですが、agreed単体でAPI定義が完結せず、結局、別個に詳細なAPIのドキュメントが必要になってくるという問題もあります。

APIの定義をする上で、必須や非必須を表現するだけではなく、このプロパティが許容する値は何なのか、値が数値の場合、最大値は何で最小値は何なのかなどを明確にしなければ、バックエンドの実装は非常に困難です。 その表現がagreed単体では不可能で、結局別のメディアにより詳細なAPI定義を行い、agreedとそのドキュメントをそれぞれ管理しなければならないという問題が発生してしまいました。 詳細なAPIドキュメントとagreedの整合性を担保する手段はなく、手動による二重管理が発生し、そのコストが無視できないレベルになってきてしまいました。

agreed-typed

上記のような問題を解決するため、agreedにtypescriptによる静的な型づけを行い、agreedファイル自体のメンテナンス性を向上させ、さらに、必須・非必須などのAPIドキュメントとしての表現力を上げるためのツールとして、agreed-typedを開発しました。

APIへの型定義の追加

従来agreedはjs, json, yamlなどのフォーマットで記述していたのですが、ここにtypescriptでも書けるように、agreed本体に機能を追加しました。agreedはrequire-hookを用いて、require時に様々なフォーマットをコンパイルし、データとして読み込むという実装がされています。そこにtypescriptのhookを加え、typescriptのファイルをrequireする際にon the flyでtranspileするようにしました。この回収により、agreedがtypescriptで記述できるようになりました。

agreed-typedは、APIDefという型をexportしていて、この型を用いてユーザはagreedを定義します。例として、上記のexampleをagreed-typedを用いて型定義していきたいと思います。

まずは、npmのprojectをinitします。

$ mkdir agreed-examples && cd agreed-examples
$ npm init -y
$ npm install agreed agreed-typed

基本的には作成したagreed-examplesのディレクトリの中で作業するものとします。

typescriptのagreedファイルを作成します。

$ vim agreed.ts

以下のように記載してください。

// save as agreed.ts
import {
    APIDef, GET, Capture, Success200, ResponseDef, convert
} from "agreed-typed";

// 必ずexportすること!
export type UserGetAPI = APIDef<
    GET, // HTTP Method
    ["user", Capture<":id">], // request path => /user/:id
    {}, // request header
    {}, // request query
    undefined, // request body
    {}, // response header
    ResponseDef<Success200, { message: string }> // response
    >;

const api: Array<UserGetAPI> = [
    {
        request: {
            path: ["user", ":id"],
            method: "GET",
            body: undefined,
        },
        response: {
            status: 200,
            body: { message: "hello {:id}" }
        }
    }
]

module.exports = convert(...api);

保存したら、このファイルを読み込んでagreed-serverを起動します。

$ agreed-server --path agreed.ts --port 3010

通常のagreedと同じように、curlで期待通りのresponseが返ってくるはずです。

$ curl  -XGET localhost:3010/user/123
{"message":"hello 123"}

コードの解説を行います。

import {
    APIDef, GET, Capture, Success200, ResponseDef, convert
} from "agreed-typed";

agreed-typedをライブラリとして読み込み、必要な型をimportしてください。VSCodeなどを使っていれば補完が効くと思います。

export type UserGetAPI = APIDef<
    GET, // HTTP Method
    ["user", Capture<":id">], // request path => /user/:id
    {}, // request header
    {}, // request query
    undefined, // request body
    {}, // response header
    ResponseDef<Success200, { message: string }> // response
    >;

ここがagreed-typedAPIの型定義の本体です。APIDef は型パラメータを7つとる筋肉質な実装になっています。HTTP VerbのGETも型として提供しています(もちろんPOST, PUT, PATCH, DELETEなども定義してあります。) なお、この型定義は必ずexportしてください! 各パラメータはどの型に当たるのかはソースコード中のコメントを参照してください。また、レポジトリのテストコードなども参考になるかと思います。 ちなみに、GETメソッドでbodyをundefined以外で指定すると、型チェックがfailするので注意してください(そういうように型を作っています)。

2つ目の型パラメータ、pathを表している部分について解説します。ここだけほかの型と気色が違います。このpathはtupleとして定義してあり、イメージとしては、pathの/で区切られた階層がtupleの要素に対応しています。 Path Parameter (:idなど)を用いるときは、Captureという型を定義してあるので、そちらを用いて、Captureの型パラメータにpath parameterの名前を入れるようにしてください。これに呼応し、agreedのpathの定義自体もtupleを用いて定義する形に変更になっています。(ちなみに、最後のconvert関数はこのtupleをもとのpathの形に変換するだけの関数です)

最後の型パラメータ、ResponseDefについて解説します。ResponseDefは2つ型パラメータをとります。1つ目はResponseのステータスコードを表す型で、これも200 - 4xx番台まですべて定義してあります。2つ目は、responseのbodyを表す型定義です。これは既存のものと同様に自由に定義できます。なぜここだけこういった形で定義しているのかというと異常系とセットで定義するときに利便性が高いからです。 このことを説明するために、上記のagreed-typedのファイルに、id=9999のときの異常系を追加してみます。

import {
   Capture,
   Success200,
   ResponseDef,
-  convert
+  convert,
+  Error404
 } from "agreed-typed";

 type UserGetAPI = APIDef<
   {}, // request query
   undefined, // request body
   {}, // response header
-  ResponseDef<Success200, { message: string }> // response
+  | ResponseDef<Success200, { message: string }>
+  | ResponseDef<Error404, { message: "user not found" }> // response
 >;

 const api: Array<UserGetAPI> = [
   {
    request: {
      path: ["user", ":id"],
      method: "GET",
      body: undefined
    },
    response: {
      status: 200,
      body: { message: "hello {:id}" }
    }
+  },
+  {
+    request: {
+      path: ["user", "9999"],
+      method: "GET",
+      body: undefined
+    },
+    response: {
+      status: 404,
+      body: { message: "user not found" }
+    }
   }
 ];

ResponseDefunion typeとして定義することにより、正常系・異常系双方のパターンを型で表現できるようになります。 また、ステータスコードとresponse bodyを別個で表現するよりも、同じ型として表現したほうが対応関係が明確になりやすいので、このようなAPIにしてあります。

型チェックを試す

せっかく型を定義したので、いくつか型チェックを試してみます。以下はVSCodeの画像を貼ってあります。エラーの文言で型チェックが落ちていることがわかると思います。

pathの型チェックを調べる

userとして定義しているpathにusersと入れてみます。 pathのパラメータはtupleとして定義してあるので、pathも型として表現されています。なので、事前に定義した型以外の値は入れられません。 Captureは唯一例外で、agreedのURLパラメータを表現するために導入されている特別な型です。以下のように任意の文字列をアサインできます。

また、Captureの第2型引数で任意の型を渡せば、その型をアサインすることができます。

未定義のresponse

わかり易い例だと、未定義のstatus codeを無理やり定義しようとすると、当然エラーになります。 status codeが200|404しか定義されていない状態で、201を定義しようとこうなります。

404のときのエラーメッセージはuser not foundと定義されているのですが、これ以外のメッセージを入れようとしてもエラーとなります。

これはuser not foundが文字列ではなく、型として定義されているからです。この機能により、特に異常系の表現力が豊かになっていると感じます。

swagger generator

agreed-typedAPIに型定義を提供するだけではなく、その型定義をパースし、Swagger(OpenAPI 2.0)の定義ファイルを出力するコマンドラインツールも提供しています。 先程のディレクトリで、

$ npx agreed-typed gen-swagger --path agreed.ts

とコマンドを実行すると、同じディレクトリにschema.jsonというファイルが生成されます。このファイルはOpenAPI2.0に準拠したjson schemaで、ここからAPI Documentationを生成することができます。 中身をすべてコピーし、swagger editorに貼り付けてみてください。 この画像のようなAPI Documentationが生成されるはずです。

さて、これではやや味気ないので、API Documentationにdescription, summaryを追加してみましょう。 先程のagreed.tsに戻り、下記のように修正してください。

+/**
+ * @summary User Greeting API
+ * @description Userに挨拶を返すAPI
+ * Errorパターン
+ * - user not found
+ *   - `id`が見つからなかった場合
+ */
export type UserGetAPI = APIDef<
   GET, // HTTP Method
   ["user", Capture<":id">], // request path => /user/:id
   {}, // request header
// 以下略

APIDefの上に、コメントで@description@summaryを追加してください。@descriptionmarkdownが使えます。ここでErrorのパターンなどを明記しておくとわかりやすいですね。 修正が完了したら、先ほどと同じコマンドをうち、schema.jsonを更新した上で、内容をコピーしてswagger editorに貼り付けてください。すると、下記の画像のようにdocumentationに説明がついていることが確認できると思います。

agreed本来の機能であるJSON Mock ServerとAPI Client Checking toolの役割はそのままに、型定義による堅牢性と、リッチなAPI Documentationが実現できるようになりました。

以上でagreed-typedの基本機能の説明は終了です。

agreed-typed in action

ここから先は思いつく限りagreed-typedの応用的な使い方を書いていきます。

POST / PATCHで型の使い回し

下記のようなPOSTのAPIを定義します。

import { APIDef, Success201, ResponseDef, Error400, POST } from "agreed-typed";

/**
 * @summary User Createg API
 * @description User作成API
 * Errorパターン
 * - user already exists
 *   - emailが重複している場合
 * - password too week
 *   - passwordが弱すぎる場合
 */
export type UserPOSTAPI = APIDef<
  POST, // HTTP Method
  ["user"], // request path => /user/:id
  {}, // request header
  {}, // request query
  {
    email: string;
    name: string;
    password: string;
  },
  {}, // response header
  | ResponseDef<Success201, { id: number }>
  | ResponseDef<Error400, { message: errorMessages }> // response
>;

/**
 * @description error messages
 * 同じstatus codeのエラーmessageはunion typeで定義すると便利
 */
type errorMessages = "password too week" | "user already exists";

このとき、request bodyは

 {
    email: string;
    name: string;
    password: string;
 }

この部分に当たるのですが、POSTの場合はすべて必須パラメータとなります。しかし、PATCHを定義しようとしたときに、部分更新を可能にしようとすると、全て非必須の以下のような型を定義し、request bodyにしたいと感じると思います。

 {
    email?: string;
    name?: string;
    password?: string;
 }

この場合、同じプロパティで非必須の型を定義するのではなく、typescriptのPartialが使えます。すなわち、下記のように書くことができます。

// ベースとなる型を定義
type User = {
  email: string;
  name: string;
  password: string;
};

// 中略
 
export type UserPOSTAPI = APIDef<
  POST, // HTTP Method
  ["user"], // request path => /user/:id
  {}, // request header
  {}, // request query
  User, // ここで使う
  {}, // response header
  | ResponseDef<Success201, { id: number }>
  | ResponseDef<Error400, { message: errorMessages }> // response
>;

// 中略

export type UserPatchAPI = APIDef<
  PATCH,
  ["user", Capture<":id">],
  {},
  {},
  Partial<User>, // Partial型を使う
  {},
  | ResponseDef<Success201, { id: number }>
  | ResponseDef<Error400, { message: errorMessages }> // response
>;

この状態で、swaggerを生成してみましょう。Postの方のrequest bodyは下のようになっているはずです。*は必須(required)を表しており、POSTの場合はすべてのパラメータが必須であることが明示されています。 一方、PATCHの方は、 requiredのパラメータが1つもないことが確認できるかと思います。このように、一度型を定義すれば、Partialなどを駆使することにより、様々な場所で再利用が可能になります。

typescript-json-schemaの機能を使う

agreed-typedは内部でtypescript-json-schemaを使っています。typescript-json-schemaはtypescriptの型定義からjson schemaに変換してくれる非常に便利なツールで、さらに、型に対しコメントを付与すると、それもjson schemaに変換してくれます。(ちなみに、APIに対する@summary / @descriptionのドキュメント変換機能はこの機能を用いておらず独自実装しています) この機能を応用することで、さらに詳細にdocumentを詳細にすることができます。 先程のUserの型定義にage: numberのプロパティを加えた上で、思いつく限りのコメントを追加していきます。

/**
 * @description userの型定義
 */
type User = {
  /**
   * @description email
   * @pattern ^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$
   */
  email: string;
  /**
   * @description 氏名
   */
  name: string;
  /**
   * @description パスワード(半角英字と半角数字それぞれ1文字以上含む8文字以上20文字以下の文字列)
   * @pattern ^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,20}$
   */
  password: string;
  /**
   * @description 年齢
   * @TJS-type integer
   * @minimum 0
   * @maximum 100
   */
  age: number;
};

この型定義を加え、swaggerを生成すると、この画像のようになります。

ここまで来ると、ほぼ完璧なAPI documentationと呼べると思います。コメントを充足させることで、さらにリッチなAPI documentationが完成するだけではなく、swagger-codegenを用いて、このschemaからAPI Server / Clientのコードを自動生成したときに、validationルールなどもセットで生成されます。 手動でやや面倒ですが、このagreed.tsをマスターとし、そこからdocumentation / server / clientを自動生成することができれば、メンテナンスのコストは大幅に削減できます。

まとめ

だいぶうちわ向けの解説となってしまいましたが、agreed-typedの解説は以上となります。

agreedに型を付け、さらにその情報からAPI documentationを生成するというツールがagreed-typedです。typescriptの型システムは非常に協力で、pathやhttp methodを含んだAPIそのものの定義を型として表現するのに十分な能力を持っていると気づき、このツールを開発しました。

なお、agreed-typedは現在私達のプロジェクトで試験的に導入したばかりで、まだ成功するかどうかわかりませんし、日々発生するバグをこまめに修正している最中です。 また、内部でtypescriptのunstableなcompiler APIなどを使用している関係もあり、ここで解説した内容が変更される可能性も十分ありますのでご注意ください。