TypeScript による Isomorphic な API Client 開発

こんにちは、フロントエンドエキスパートチームの @koba04 です。

本記事では、kintone の REST API を使うためのクライアントである @kintone/rest-api-client (以下 rest-api-client) の構成や工夫した点について紹介します。

本記事は rest-api-client の 1.6.0 のバージョンに基づいています。

@kintone/rest-api-client とは

rest-api-client とは、kintone が提供する REST API を利用するためのクライアントライブラリです。 GitHub 上は kintone/js-sdk の Monorepo の 1 パッケージとして開発されています。

kintone/js-sdk での Monorepo 開発については下記の記事を参照してください。

https://blog.cybozu.io/entry/2020/04/21/080000

rest-api-client は Isomorphic、つまりブラウザ環境と Node.js 環境どちらでも動作するように作られています。

全体の構成

パッケージ全体のディレクトリ構成は下記の通りです。

➜  tree -L 2 packages/rest-api-client/src
packages/rest-api-client/src
├── KintoneFields
│   ├── exportTypes
│   └── types
├── KintoneRequestConfigBuilder.ts
├── KintoneResponseHandler.ts
├── KintoneRestAPIClient.ts
├── __tests__
│   ├── KintoneRequestConfigBuilder.test.ts
│   ├── KintoneResponseHandler.test.ts
│   ├── KintoneRestAPIClient.test.ts
│   ├── setup.ts
│   └── url.test.ts
├── client
│   ├── AppClient.ts
│   ├── BulkRequestClient.ts
│   ├── FileClient.ts
│   ├── RecordClient.ts
│   ├── __tests__
│   └── types
├── error
│   ├── KintoneAbortSearchError.ts
│   ├── KintoneAllRecordsError.ts
│   ├── KintoneRestAPIError.ts
│   └── __tests__
├── http
│   ├── AxiosClient.ts
│   ├── HttpClientInterface.ts
│   ├── MockClient.ts
│   └── index.ts
├── index.browser.ts
├── index.ts
├── platform
│   ├── UnsupportedPlatformError.ts
│   ├── __tests__
│   ├── browser.ts
│   ├── index.ts
│   └── node.ts
└── url.ts

今回は上記の中でも client/http/platform/ ディレクトリについて解説していきます。

依存関係の制御

前述した通り、rest-api-client はブラウザ環境でも Node.js 環境でも動作するように作られています。 ブラウザ環境と Node.js 環境固有の処理が混在しないように、環境毎の依存を抽象化出来るように設計されています。

HTTP Client

rest-api-client では HTTP Client として axios を利用しています。 axios 自体がブラウザ環境でも Node.js でも動作するように作られているため、直接使ってもブラウザ、Node.js 環境で動作するクライアントを作ることは可能です。 しかし、rest-api-client では HTTP Client のインターフェイスを定義して抽象化した上で利用しています。

下記が HttpClientInterface です。

import FormData from "form-data";
export interface HttpClient {
  get: <T extends object>(path: string, params: object) => Promise<T>;
  getData: (path: string, params: object) => Promise<ArrayBuffer>;
  post: <T extends object>(path: string, params: object) => Promise<T>;
  postData: <T extends object>(path: string, params: FormData) => Promise<T>;
  put: <T extends object>(path: string, params: object) => Promise<T>;
  delete: <T extends object>(path: string, params: object) => Promise<T>;
}

export type ErrorResponse<T = any> = {
  data: T;
  status: number;
  statusText: string;
  headers: any;
};

export type Response<T = any> = {
  data: T;
  headers: any;
};

export type HttpMethod = "get" | "post" | "put" | "delete";
export type Params = { [key: string]: unknown };

export type ProxyConfig = {
  host: string;
  port: number;
  auth?: {
    username: string;
    password: string;
  };
};

export interface HttpClientError<T = ErrorResponse> extends Error {
  response?: T;
}
export interface ResponseHandler {
  handle: <T = any>(response: Promise<Response<T>>) => Promise<T>;
}

export type RequestConfig = {
  method: HttpMethod;
  url: string;
  headers: any;
  httpsAgent?: any;
  data?: any;
  proxy?: ProxyConfig;
};

export interface RequestConfigBuilder {
  build: (
    method: HttpMethod,
    path: string,
    params: Params | FormData,
    options?: { responseType: "arraybuffer" }
  ) => Promise<RequestConfig>;
}

http のレイヤーの外では、上記の Interface のみに依存しており、詳細である実装には依存していません。 HttpClientInterface のインターフェイスを axios をベースに実装したものが、AxiosClient です。

// HttpClient Interface を実装する
export class AxiosClient implements HttpClient {
  private responseHandler: ResponseHandler;
  private requestConfigBuilder: RequestConfigBuilder;

  constructor({
    responseHandler,
    requestConfigBuilder,
  }: {
    responseHandler: ResponseHandler;
    requestConfigBuilder: RequestConfigBuilder;
  }) {
    this.responseHandler = responseHandler;
    this.requestConfigBuilder = requestConfigBuilder;
  }

  public async get(path: string, params: any) {
    // kintone の REST API を実行するための形式に組み立てる
    const requestConfig = await this.requestConfigBuilder.build(
      "get",
      path,
      params
    );
    return this.sendRequest(requestConfig);
  }

  public async post(path: string, params: any) {
    // kintone の REST API を実行するための形式に組み立てる
    const requestConfig = await this.requestConfigBuilder.build(
      "post",
      path,
      params
    );
    return this.sendRequest(requestConfig);
  }
  
  // 省略

  private sendRequest(requestConfig: RequestConfig) {
    // Axios が返す Promise を constructor で受け取った Response を処理するハンドラーに渡す
    return this.responseHandler.handle(
      // eslint-disable-next-line new-cap
      Axios({
        ...requestConfig,

        // NOTE: For defining the max size of the http request content, `maxBodyLength` will be used after version 0.20.0.
        // `maxContentLength` will be still needed for defining the max size of the http response content.
        // ref: https://github.com/axios/axios/pull/2781/files
        // maxBodyLength: Infinity,

        maxContentLength: Infinity,
      })
    );
  }
}

HTTP Client 自体のインターフェイスだけでなくレスポンスについても http のレイヤーにインターフェイスとして定義しているため、axios から別の HTTP Client に変えたい場合にも http のレイヤーの中で対応できます。 現状は axios を使うことで Node.js 環境での Proxy やクライアント証明書対応が簡単に行えるようになっていますが、将来的にブラウザ環境と Node.js 環境それぞれで異なる HTTP Client を使いたいという場合にも対応可能な設計になってます。

また、AxiosClient 以外の HttpClientInterface の実装として、MockClient を用意しています。 これは主に単体テスト時に利用することを想定した HTTP Client で、HttpClientInterface の実装に加えて、任意のレスポンスを返したりリクエストのログを記録する機能を持っています。 これにより、HTTP のレイヤーをモックしたい場合にもモックライブラリを使うことなく、HTTP リクエストを伴う処理に対してテストを書くことができます。

  let mockClient: MockClient;
  let recordClient: RecordClient;
  beforeEach(() => {
    const requestConfigBuilder = new KintoneRequestConfigBuilder({
      baseUrl: "https://example.cybozu.com",
      auth: { type: "apiToken", apiToken: "foo" },
    });
    // MockClient を HTTP Client として設定
    mockClient = buildMockClient(requestConfigBuilder);
    const bulkRequestClient = new BulkRequestClient(mockClient);
    recordClient = new RecordClient(mockClient, bulkRequestClient);
  });
  describe("addRecords", () => {
    const params = { app: APP_ID, records: [record] };
    const mockResponse = {
      ids: ["10", "20", "30"],
      revisions: ["1", "2", "3"],
    };
    let response: any;
    beforeEach(async () => {
      // モックのレスポンスを指定
      mockClient.mockResponse(mockResponse);
      response = await recordClient.addRecords(params);
    });
    it("should pass the path to the http client", () => {
      expect(mockClient.getLogs()[0].path).toBe("/k/v1/records.json");
    });
    it("should send a post request", () => {
      expect(mockClient.getLogs()[0].method).toBe("post");
    });
    it("should pass app and records to the http client", () => {
      expect(mockClient.getLogs()[0].params).toEqual(params);
    });
    it("should return a response having ids, revisions, and records", () => {
      expect(response).toEqual({
        ...mockResponse,
        records: [
          { id: "10", revision: "1" },
          { id: "20", revision: "2" },
          { id: "30", revision: "3" },
        ],
      });
    });
  });

HttpClientInterface 以外では、エラー情報である HttpClientError やリクエスト・レスポンスを処理するための RequestConfigRequestConfigBuilderResponseHandler を提供しています。

RequestConfigBuilderResponseHandler はそれぞれリクエスト・レスポンス時に、HTTP Client のレイヤーで kintone のドメインに関する処理を行うためのモジュールです。 例えば、RequestConfigBuilder は GET リクエストの URI の長さが一定値を超えた場合に X-HTTP-Method-Override を使った POST リクエストに切り替えるといった処理を行っています。 これらは kintone のドメインに関する処理であり、http のレイヤーに書く処理としては不適切なため、外側から渡す形にしています。

KintoneResponseHandler では、レスポンスに対するエラーハンドリングが行われています。

環境毎の依存

上記は HTTP Client のレイヤーの話ですが、他にも認証方法などブラウザと Node.js 環境での違いがあります。 それを処理するのが platform ディレクトリの役割です。

Platform のレイヤーでは下記のようなインターフェイスを用いて抽象化を行っています。

type PlatformDeps = {
  readFileFromPath: (
    filePath: string
  ) => Promise<{ name: string; data: unknown }>;
  getRequestToken: () => Promise<string>;
  getDefaultAuth: () => DiscriminatedAuth;
  buildPlatformDependentConfig: (params: object) => object;
  buildHeaders: () => Record<string, string>;
  buildFormDataValue: (data: unknown) => unknown;
  buildBaseUrl: (baseUrl?: string) => string;
  getVersion: () => string;
};

これらの処理を platform/browser.tsplatform/node.ts で実装しています。 一方の環境でしかサポートしていない場合は UnsupportedPlatformError という専用のエラーを投げるようになっています。

export const readFileFromPath = async (filePath: string) => {
  const data = await readFile(filePath);
  const name = basename(filePath);
  return { data, name };
};

export const getRequestToken = () => {
  // この関数はブラウザ環境のみ
  throw new UnsupportedPlatformError("Node.js");
};

export const getDefaultAuth = () => {
  // この関数はブラウザ環境のみ
  throw new UnsupportedPlatformError("Node.js");
};

export const buildPlatformDependentConfig = (params: {
  clientCertAuth?:
    | {
        pfx: Buffer;
        password: string;
      }
    | {
        pfxFilePath: string;
        password: string;
      };
}) => {
  const clientCertAuth = params.clientCertAuth;

  // クライアント証明書対応
  if (clientCertAuth) {
    const pfx =
      "pfx" in clientCertAuth
        ? clientCertAuth.pfx
        : fs.readFileSync(clientCertAuth.pfxFilePath);
    const httpsAgent = new https.Agent({
      pfx,
      passphrase: clientCertAuth.password,
    });
    return { httpsAgent };
  }
  return {};
};

export const buildHeaders = () => {
  return {
    "User-Agent": `Node.js/${process.version}(${os.type()}) ${
      packageJson.name
    }@${packageJson.version}`,
  };
};

export const buildFormDataValue = (data: unknown) => {
  return data;
};

export const buildBaseUrl = (baseUrl: string | undefined) => {
  if (typeof baseUrl === "undefined") {
    throw new Error("in Node.js environment, baseUrl is required");
  }
  return baseUrl;
};

export const getVersion = () => {
  return packageJson.version;
};

ブラウザと Node.js 環境での依存を切り替える方法ですが、実行時に動的に切り替えようとすると webpack などのモジュールバンドラによるビルド時に Node.js 環境用の依存パッケージがブラウザ用のビルドに含まれてしまいます。 rest-api-client 自体も Rollup による UMD ビルドを提供しており、不要なパッケージやポリフィルによるファイルサイズが大きくなることは避けたいと考えています。

そのため、rest-api-client では、エントリーポイントをブラウザと Node.js 環境で分離し、エントリーポイントで依存を挿入する形で対応しました。

import { injectPlatformDeps } from "./platform/";
import * as browserDeps from "./platform/browser";

injectPlatformDeps(browserDeps);

export { KintoneRestAPIClient } from "./KintoneRestAPIClient";
import { injectPlatformDeps } from "./platform/";
import * as nodeDeps from "./platform/node";

injectPlatformDeps(nodeDeps);

export { KintoneRestAPIClient } from "./KintoneRestAPIClient";
export * as KintoneRecordField from "./KintoneFields/exportTypes/field";
export * as KintoneFormLayout from "./KintoneFields/exportTypes/layout";
export * as KintoneFormFieldProperty from "./KintoneFields/exportTypes/property";

エントリーポイントで設定されたプラットフォーム依存の処理は、下記のように利用します。

export class KintoneRestAPIClient {
  record: RecordClient;
  app: AppClient;
  file: FileClient;
  private bulkRequest_: BulkRequestClient;
  private baseUrl?: string;

  constructor(options: Options = {}) {
    this.baseUrl = platformDeps.buildBaseUrl(options.baseUrl);

    // 以下略

これにより単体テストでも、プラットフォーム依存の処理を置き換えたり、ブラウザ環境、Node.js 環境を想定したテストも書けるようになっています。

Client

rest-api-client が提供する機能はいくつかのカテゴリに分類出来るため、それぞれのカテゴリ毎に Client を作成しています。

これらの Client を作成する際に上記で作成した HTTP Client を渡す形になっているため Client をテストする際は、HTTP Client として MockClient を渡すことで実際の API にアクセスすることなくテストしています。

現状は MockClient が返すデータは都度テスト内で準備しているため、将来的にはモック API を提供する仕組みも検討したいと考えています。

その他の工夫

Feature Flags による機能追加

プロダクトで利用しているライブラリが破壊的変更を行うことはメジャーバージョンアップであっても利用者に負担を与えます。 そのため rest-api-client では、破壊的変更を行う際には可能な限り段階的なアップデートプランを提供したいと考えています。 そのための仕組みとして、Feature Flag の仕組みを取り入れています。

参考: https://github.com/kintone/js-sdk/pull/304

これにより、Feature Flag をデフォルトではオフにした状態でマイナーバージョンとしてリリースし、opt-in により新機能を試せる状態にしています。 その後デフォルトで Feature Flag をオンにする際には、そのオプション自体は残したままメジャーバージョンとしてリリースして、opt-out で無効化出来るようにします。

場合によっては、Feature Flag を適用できない場合も想定されますが、その場合でも warning メッセージを出すなど可能な限り破壊的変更時にもプロダクトが壊れないようにと考えています。

ToDo から実装する

rest-api-client は単体テストフレームワークとして、Jest を利用しています。 Jest は it.todo("should be bar"); のように Todo として先にテストケースを書くことができます。 rest-api-client ではこの機能を利用して、仕様を Todo として記述していき、Todo となっているテストを実装しながら進めるという方法を取りました。

これにより、仕様を考えることと実装することがプロセスとして分離され、議論がしやすくなりました。

また、出入力がはっきりしている純粋関数に対しては、test.each を利用して一覧性の高いテストを書いています。

test.each`
  endpointName | guestSpaceId | preview      | expected
  ${"record"}  | ${undefined} | ${undefined} | ${"/k/v1/record.json"}
  ${"record"}  | ${undefined} | ${false}     | ${"/k/v1/record.json"}
  ${"record"}  | ${undefined} | ${true}      | ${"/k/v1/preview/record.json"}
  ${"record"}  | ${3}         | ${undefined} | ${"/k/guest/3/v1/record.json"}
  ${"record"}  | ${3}         | ${false}     | ${"/k/guest/3/v1/record.json"}
  ${"record"}  | ${3}         | ${true}      | ${"/k/guest/3/v1/preview/record.json"}
  ${"record"}  | ${"3"}       | ${undefined} | ${"/k/guest/3/v1/record.json"}
  ${"record"}  | ${"3"}       | ${false}     | ${"/k/guest/3/v1/record.json"}
  ${"record"}  | ${"3"}       | ${true}      | ${"/k/guest/3/v1/preview/record.json"}
`("buildPath", ({ endpointName, guestSpaceId, preview, expected }) => {
  expect(buildPath({ endpointName, guestSpaceId, preview })).toBe(expected);
});

まとめ

このようにサイボウズでは、プロダクト本体だけでなくサイボウズ外の開発者の開発体験を向上させるためのツール開発も行っています。 このような設計は、個人で行うだけでなく普段のモブプログラミングを通じて複数人で議論しながら進めています。

サイボウズでは、Web アプリケーションのフロントエンドだけでなく、プラットフォームとして開発体験を向上させるためのツール開発を OSS でやりたいというエンジニアも絶賛募集中です!

https://cybozu.co.jp/company/job/recruitment/list/front_end_expert.html