良いREST APIクライアントの設計というものに関心があり、GitHub社の公式REST APIクライアントである @octokit/rest のコードを読んでみたメモです。
(ドキュメントは https://octokit.github.io/rest.js/)
知りたかったこと
漠然と、こういう疑問に対してなんらか知見が得られればいいなーと思っていました。
- 全体的なディレクトリ構成やレイヤー。どういうふうに責務を分けているか
- 各APIエンドポイントに対応するメソッドはどのように実装している?命名ルールやnamespaceなどの分割基準は?
- Node.js環境とブラウザ環境を両方サポートするために、環境の差異をどのように吸収しているのか
- エラーハンドリングはどうしてる?
- テストはどうしてる?
- 何に対してテストを書いているか。リクエスト部分のモックはどうしているか
調べてわかったことメモ
以下、特にまとまりなく書いてます。
@octokit/restのアーキテクチャ全体像
ライブラリ利用者目線での全体像は HOW_IT_WORKS.md がわかりやすいです。
(画像はリンク先から引用)
図および本文から
- API リクエストの前後に任意の処理を挟める Hooks という機構があること
- @octokit/request と @octokit/endpoint という別のライブラリがあること
などがわかりました。
@octokit/restと@octokit/request, @octokit/endpoint
依存関係は
@octokit/rest --(require)--> @octokit/request --(require)--> @octokit/endpoint
の順になっています。
それぞれの役割については以下のとおりです。
@octokit/endpoint
GitHub REST APIのエンドポイント(例:GET /gists/:gist_id
) と必要なパラメータを受け取り、fetch APIやaxiosに渡せる汎用的なリクエストオブジェクトに変換する。
(例)
const { endpoint } = require('@octokit/endpoint'); const options = endpoint('POST /repos/:owner/:repo/branches/:branch/protection/required_status_checks/contexts', { owner: 'zaki-yama', repo: 'my-repo', branch: 'my-branch', }); console.log(options);
↑の結果
{ method: 'POST', url: 'https://api.github.com/repos/zaki-yama/my-repo/branches/my-branch/protection/required_status_checks/contexts', headers: { accept: 'application/vnd.github.v3+json', 'user-agent': 'octokit-endpoint.js/5.4.1 Node.js/10.15.3 (macOS Mojave; x64)', 'content-length': 0 } }
これ単体で使うことはなさそうですが、引数に補完が効くあたりは面白いというか便利だなと思いました。
@octokit/request
先ほどの @octokit/endpoint を使い、実際にAPIを叩くところを行う。
APIリクエスト時のNode.js/ブラウザ環境の差分をどうしてるかとかはここを見ると良さそうです。
@octokit/rest
@octokit/request に認証まわりの機能や Hooks などの機構を追加して、最終的なAPIクライアントの形にしたもの。
(@octokit/request) Node.js環境とブラウザ環境の考慮は?
APIリクエスト部分は@octokit/requestがやっているということがわかったので、こっちを見てみます。
READMEには
It uses @octokit/endpoint to parse the passed options and sends the request using fetch (node-fetch in Node).
とあるので、ブラウザ環境ではfetch API、Node.js環境ではnode-fetchというライブラリを利用しているようです。
ところが、fetchを行っている https://github.com/octokit/request.js/blob/v5.3.1/src/fetch-wrapper.ts を見ても、動作環境の判定らしき処理はありません。
(https://github.com/octokit/request.js/blob/v5.3.1/src/fetch-wrapper.ts#L22-L36 を抜粋)
const fetch: typeof nodeFetch = (requestOptions.request && requestOptions.request.fetch) || nodeFetch; return fetch( requestOptions.url, Object.assign( { method: requestOptions.method, body: requestOptions.body, headers: requestOptions.headers, redirect: requestOptions.redirect }, requestOptions.request ) )
注)1, 2行目の requestOptions.request.fetch
はオプションとして渡すとライブラリ利用者が任意のfetch関数に差し替えることができるもの
そこで node-fetch を見てみると、package.jsonで browser
フィールドを指定している (そしてその中身は window.fetch
) ことがわかります。
また webpack のドキュメントを読むと
https://webpack.js.org/configuration/resolve/#resolvemainfields
モジュールをimportする際、package.jsonの browser
-> module
-> main
フィールドの順に探しにいくようです。(target
未指定の場合)
そのため、webpackなどのモジュールバンドラーを使ってバンドルしたJSはwindow.fetch
を使うようになりそうです。
よって動作環境の違いはnode-fetch側でケアしており、@octokit/requestは特に何もしてませんでした。
(@octokit/rest) 各APIに対応するメソッドはどのように生成しているのか
@octokit/rest 内のコードを検索しても、octokit.repos.listForOrg()
のようなメソッドはヒットしません。
で、調べてみたところ、以下のようなしくみで自動生成しているようでした。
- scripts/update-endpoints/fetch-json.js というスクリプトは、https://octokit-routes-graphql-server.now.sh/ というGraphQL APIを叩いてAPIのスキーマ情報を取得し、endpoints.json というJSONを生成する
- 次に、scripts/update-endpoints/code.jsという別のスクリプトは、endpoints.jsonをインプットとして多少処理を加えroutes.jsonというJSONを生成する
生成されるroutes.jsonの中身はこんな感じになっている。
(https://github.com/octokit/rest.js/blob/master/plugins/rest-api-endpoints/routes.json#L4527-L4544 あたり)
{ ... "repos": { "listForOrg": { "method": "GET", "params": { "direction": { "enum": ["asc", "desc"], "type": "string" }, "org": { "required": true, "type": "string" }, "page": { "type": "integer" }, "per_page": { "type": "integer" }, "sort": { "enum": ["created", "updated", "pushed", "full_name"], "type": "string" }, "type": { "enum": ["all", "public", "private", "forks", "sources", "member"], "type": "string" } }, "url": "/orgs/:org/repos" }, ... }, ... }
JSONの第一階層(repos
)がGraphQL APIのscope
、第二階層(listForOrg
)がid
に対応している。
- このroutes.jsonを処理しているのは
octokit.registerEndpoints()
という関数で、ここに定義されている - やっていることはひたすら
octokit.<scope>.<id>
で呼び出せる関数を生やす作業 - code.jsと同じようなノリで、scripts/update-endpoints/typescript.jsはendpoints.jsonからTypeScript型定義を生成している
余談ですが、このoctokit.registerEndpoints()
はライブラリ利用者が任意のエンドポイントを生やす関数として提供されているものでもあります。
参考:https://octokit.github.io/rest.js/#custom-endpoint-methods
(@octokit/request) エラーハンドリングはどのように行っている?
https://github.com/octokit/request.js/blob/master/src/fetch-wrapper.ts
RequestError
という独自のエラークラスをthrowしています。エラークラスの実体は @octokit/request-errorという別のライブラリになっています。
APIリクエスト時に発生したエラーはすべてこのカスタムエラーとしてthrowする方針のようですね。
https://github.com/octokit/request.js/blob/master/src/fetch-wrapper.ts#L121-L124
Hooksのしくみはどうやって実現している?
https://octokit.github.io/rest.js/#hooks
よくわかってませんが、https://github.com/gr2m/before-after-hook というライブラリを使うとさくっと実現できる?
感想 & まだ調べられてないこと
パッケージレベルでかなり細かく分割してそれぞれをシンプルに保っているなあという点と、スキーマ情報から各エンドポイントに対応するメソッドを自動生成してるところなどはなるほどなあと思いました。
まだ調べられてないところとしてはテスト周りでしょうか。
どうやら一部のテスト(test/scenarios/
以下)のために @octokit/fixtures-server というモックサーバーライブラリを使用しているらしく、このあたりも調べてみるとまた新たな学びがありそうです。