Quantcast
Channel: Cybozu Inside Out | サイボウズエンジニアのブログ
Viewing all articles
Browse latest Browse all 681

TypeScript による Isomorphic な API Client 開発

$
0
0

こんにちは、フロントエンドエキスパートチームの @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";exportinterface 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>;}exporttype ErrorResponse<T =any>={
  data: T;status: number;
  statusText: string;
  headers: any;};exporttype Response<T =any>={
  data: T;
  headers: any;};exporttype HttpMethod ="get" | "post" | "put" | "delete";exporttype Params ={[key: string]: unknown };exporttype ProxyConfig ={
  host: string;
  port: number;
  auth?: {
    username: string;
    password: string;};};exportinterface HttpClientError<T = ErrorResponse>extendsError{
  response?: T;}exportinterface ResponseHandler {
  handle: <T =any>(response: Promise<Response<T>>)=> Promise<T>;}exporttype RequestConfig ={
  method: HttpMethod;
  url: string;
  headers: any;
  httpsAgent?: any;
  data?: any;
  proxy?: ProxyConfig;};exportinterface RequestConfigBuilder {
  build: (
    method: HttpMethod,
    path: string,
    params: Params | FormData,
    options?: { responseType: "arraybuffer"})=> Promise<RequestConfig>;}

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

// HttpClient Interface を実装するexportclass AxiosClient implements HttpClient {private responseHandler: ResponseHandler;private requestConfigBuilder: RequestConfigBuilder;constructor({
    responseHandler,
    requestConfigBuilder,}: {
    responseHandler: ResponseHandler;
    requestConfigBuilder: RequestConfigBuilder;}){this.responseHandler = responseHandler;this.requestConfigBuilder = requestConfigBuilder;}publicasync get(path: string, params: any){// kintone の REST API を実行するための形式に組み立てるconst requestConfig =awaitthis.requestConfigBuilder.build("get",
      path,
      params
    );returnthis.sendRequest(requestConfig);}publicasync post(path: string, params: any){// kintone の REST API を実行するための形式に組み立てるconst requestConfig =awaitthis.requestConfigBuilder.build("post",
      path,
      params
    );returnthis.sendRequest(requestConfig);}// 省略private sendRequest(requestConfig: RequestConfig){// Axios が返す Promise を constructor で受け取った Response を処理するハンドラーに渡すreturnthis.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という専用のエラーを投げるようになっています。

exportconst readFileFromPath =async(filePath: string)=>{const data =await readFile(filePath);const name = basename(filePath);return{ data, name };};exportconst getRequestToken =()=>{// この関数はブラウザ環境のみthrownew UnsupportedPlatformError("Node.js");};exportconst getDefaultAuth =()=>{// この関数はブラウザ環境のみthrownew UnsupportedPlatformError("Node.js");};exportconst 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{};};exportconst buildHeaders =()=>{return{"User-Agent": `Node.js/${process.version}(${os.type()}) ${      packageJson.name    }@${packageJson.version}`,};};exportconst buildFormDataValue =(data: unknown)=>{return data;};exportconst buildBaseUrl =(baseUrl: string | undefined)=>{if(typeof baseUrl ==="undefined"){thrownewError("in Node.js environment, baseUrl is required");}return baseUrl;};exportconst 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";

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

exportclass 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


Viewing all articles
Browse latest Browse all 681

Trending Articles