agreed-typedの紹介
はじめに
agreed-typedというツールを現在開発中なので、その紹介をします。
自分は普段バックエンドを書いているエンジニアで、フロントエンドエンジニア向けのツールであるagreedをメインで活用することはないのですが、APIのドキュメンテーションや、バックエンドとフロントエンドのAPIの整合性をとるという観点から、agreedを補完するためのツールとしてagreed-typedを開発しています。 その背景と、agreed-typedで実現したことを紹介していきます。
以下のサンプルコードはすべてGithubにupしてあります。
agreedとはなにか
agreed-typedの説明をする前に、agreedの説明をします。
agreedの紹介
agreedはリクルートテクノロジーズがOSSとして公開している、Consumer Driven ContractおよびJSON Mock Serverのツールです。agreedの詳しい紹介については、この記事をみていただきたいのですが、簡単にまとめると、APIの仕様定義をバックエンド側(API提供側)が決めるのではなく、フロントエンド(API使用側)が主導する Consumer Driven Contract を達成するためのツールで、フロントエンドエンジニアがまずjsやjson、yamlなどでAPI定義を記述し、バックエンド側がそれを実装するといった開発フローを実現します。
実際の開発フローを以下に記載していきます。
1. agreed fileの記述
フロントエンドエンジニアが、画面の描画に必要なAPIをjson(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というコマンドがインストールされます。 --path
optionで、記述した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/:id
のAPIに拡張して、異常系を追加しようと思います。
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-typed
のAPIの型定義の本体です。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" } + } } ];
ResponseDef
をunion 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-typed
はAPIに型定義を提供するだけではなく、その型定義をパースし、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
を追加してください。@description
はmarkdownが使えます。ここで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などを使用している関係もあり、ここで解説した内容が変更される可能性も十分ありますのでご注意ください。