dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

@octokit/restのアーキテクチャについて調べた

良いREST APIクライアントの設計というものに関心があり、GitHub社の公式REST APIクライアントである @octokit/rest のコードを読んでみたメモです。

(ドキュメントは https://octokit.github.io/rest.js/)

知りたかったこと

漠然と、こういう疑問に対してなんらか知見が得られればいいなーと思っていました。

  • 全体的なディレクトリ構成やレイヤー。どういうふうに責務を分けているか
  • APIエンドポイントに対応するメソッドはどのように実装している?命名ルールやnamespaceなどの分割基準は?
  • Node.js環境とブラウザ環境を両方サポートするために、環境の差異をどのように吸収しているのか
  • エラーハンドリングはどうしてる?
  • テストはどうしてる?
    • 何に対してテストを書いているか。リクエスト部分のモックはどうしているか

調べてわかったことメモ

以下、特にまとまりなく書いてます。


@octokit/restのアーキテクチャ全体像

ライブラリ利用者目線での全体像は HOW_IT_WORKS.md がわかりやすいです。

f:id:dackdive:20191108220724p:plain

(画像はリンク先から引用)

図および本文から

などがわかりました。


@octokit/restと@octokit/request, @octokit/endpoint

依存関係は

@octokit/rest --(require)--> @octokit/request --(require)--> @octokit/endpoint

の順になっています。
それぞれの役割については以下のとおりです。


@octokit/endpoint

GitHub REST APIのエンドポイント(例:GET /gists/:gist_id) と必要なパラメータを受け取り、fetch APIaxiosに渡せる汎用的なリクエストオブジェクトに変換する。

(例)

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 } }

これ単体で使うことはなさそうですが、引数に補完が効くあたりは面白いというか便利だなと思いました。

f:id:dackdive:20191108225534p:plain


@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.jsonbrowser -> module -> main フィールドの順に探しにいくようです。(target未指定の場合)

そのため、webpackなどのモジュールバンドラーを使ってバンドルしたJSはwindow.fetchを使うようになりそうです。
よって動作環境の違いはnode-fetch側でケアしており、@octokit/requestは特に何もしてませんでした。


(@octokit/rest) 各APIに対応するメソッドはどのように生成しているのか

@octokit/rest 内のコードを検索しても、octokit.repos.listForOrg()のようなメソッドはヒットしません。
で、調べてみたところ、以下のようなしくみで自動生成しているようでした。

生成される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 APIscope、第二階層(listForOrg)がidに対応している。

  • このroutes.jsonを処理しているのはoctokit.registerEndpoints()という関数で、ここに定義されている
  • やっていることはひたすら octokit.<scope>.<id> で呼び出せる関数を生やす作業
  • code.jsと同じようなノリで、scripts/update-endpoints/typescript.jsはendpoints.jsonからTypeScript型定義を生成している
    • Mustacheを使っている
    • さらに、似たようなスクリプトが @octokit/endpoint にもある(↑で書いた補完が効くのはそのおかげ)

余談ですが、この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 というモックサーバーライブラリを使用しているらしく、このあたりも調べてみるとまた新たな学びがありそうです。