🔏

microcms-js-sdkをtype-safeに扱いたい

2022/11/18に公開

普段私は、microCMSを頻繁に利用させていただいています。
そしてありがたいことに、コンテンツのやり取りを行う為に公式さんがmicrocms-js-sdkというSDKを提供してくれています!
公式からこういうSDKが出ているのはすごく嬉しいですね〜!

そんなSDKを日々使う中、私個人的にある不満が出てきました。

  • endpontを誤ってtypoしてしまうことがある...
  • 型定義を開発者側の裁量に任せられている。もっと楽したい!

今回はそんなお悩みがモチベーションです。
ざっくりまとめると、俺が考える最強のtype-safeなSDKにカスタマイズしてやるぜ! です。

今回の記事のソースコードはリポジトリにて管理していますので参考になれば幸いです。
https://github.com/tsuki-lab/microcms-ts-sdk

🤔 課題の整理

では、具体的に何を解決したいのか課題化していきます。

課題.1 endpoint指定が開発者の裁量に任せられている。

microcms-js-sdkの利用方法をあらためて確認してみましょう。

  import { createClient } from 'microcms-js-sdk';

  const client = createClient({
    serviceDomain: 'foo',
    apiKey: 'xxxxxxxx'
  });

  client
    .getList({
      endpoint: 'piyo'
    })
    .then((res) => console.log(res))
    .catch((err) => console.log(err));

createClientを用いてクライアントを作成して、getListを用いてリストコンテンツを取得する。
これが一番基本の利用方法かと思います。

この際に、endpointを指定する必要があるのですが、これがmicroCMSのワークスペースでどのようなエンドポイントを作っているのか。というのを開発側で理解していないと404エラーなどになってしまう問題を抱えています。

例えば、aspida + microCMS という組み合わせでエンドポイントをいい感じにする方法があります。
https://blog.microcms.io/how-to-use-aspida/

aspidaを用いると実際の呼び出しは下記のようになります。

  await client.piyo.$get();

オブジェクトを掘るような形でAPIを呼び出せるのは直感的ですね。
私も以前はよく利用していました。

しかし、microCMSのAPI数が増えた際にクライアントを作成するための似たようなファイルを大量に作成する必要があるなど、ファイル管理がちょっと億劫になってしまい、もやもやしてしまいました。

少し複雑なREST APIクライアントを作成する際にはaspidaは重宝しますが、シンプルなAPI呼び出し設計になっているmicroCMSには少しオーバースペックかなと感じました。

私の理想系としては、下記のようにendpointの指定がtype-safeになればなぁと思います。

  client
    .getList({
      endpoint: 'piyo' // type-safe
    })
    .then((res) => console.log(res))
    .catch((err) => console.log(err));

課題.2 型定義を開発者側の裁量に任せられている。

次の課題です。

microcms-js-sdkは、TypeScriptのサポートもしています。

例えば、下記のような形で型定義を当てることができます。

  type Piyo = {
    title: string;
    body: string;
  }

  const result = await client.getList<Piyo>({ endpoint: 'piyo' });
  console.log(result.contents[0]);
  // {
  //   id: string;
  //   title: string;
  //   body: string;
  //   createdAt: string;
  //   updatedAt: string;
  //   publishedAt?: string;
  //   revisedAt?: string;
  // }

しかし、この型定義にもエンドポイントと同様に開発者の裁量が求められています。

ヒューマンエラーケースとして、エンドポイントと型定義が間違えて指定しまうなどが考えられるかなと思います。

  client.getList<Piyo>({ endpoint: 'hoge' });

他にも裁量に任せられている課題として、microCMSのAPIのgetにはfieldsというクエリパラメータがあります。
これは、APIのレスポンスに含まれるコンテンツの中で取得する要素を指定できるというものです。
https://document.microcms.io/content-api/get-list-contents#h7462d83de4

実際の利用方法は下記のような形です。

  client.getList({
    endpoint: 'piyo',
    queries: {
      fields: ['id', 'title', 'publishedAt']
    },
  });

しかしながら、TypeScriptを扱う身としては、type-safeを身体が求めてしまいます。
まず、fieldsの型定義ですがmicrocms-js-sdkでは、string[]|stringとなっています。
これが、keyof(Piyo | MicroCMSListContent)となっていて欲しいですね...
そうなっていることで、存在しないフィールドの指定の防止がtype-safeに行うことができます。

次にレスポンスについてです。
fieldsに指定したパラメーターにより実際のレスポンスは指定した要素のみになっていますが、getListの型定義を怠った場合には、残念なことが起こります。

  client.getList<Piyo>({
    endpoint: 'piyo',
    queries: {
      fields: ['id', 'title', 'publishedAt']
    },
  });
  // こうなってしまう。。😭
  // {
  //   id: string;
  //   title: string;
  //   body: string;
  //   createdAt: string;
  //   updatedAt: string;
  //   publishedAt?: string;
  //   revisedAt?: string;
  // }

こうなってしまったら、型が定義されているから呼び出せるのに、実データが存在しない...なんてことが起きてしまいます。

ちゃんとしたレスポンスを求めるなら下記のようにしないといけませんね。

  client.getList<Pick<Piyo, 'id'|'title'|'publishedAt'>>({
    endpoint: 'piyo',
    queries: {
      fields: ['id', 'title', 'publishedAt']
    },
  });
  // {
  //   id: string;
  //   title: string;
  //   publishedAt?: string;
  // }

以上のように型定義全般が開発者の裁量に任せられています。
「型補完の恩恵をもっと楽に受けたい!!」という私の堕落した心が叫んでいます。

こうなるといいなぁと私は思います。

  client.getList({
    endpoint: 'piyo', // type-safe
    queries: {
      fields: ['id', 'title', 'publishedAt'] // type-safe
    },
  });
  // Response Types:
  // {
  //   contents: Pick<Piyo & MicroCMSContent, 'id'|'title'|'publishedAt'>[];
  //   totalcunt: number;
  //   offset: number;
  //   limit: number;
  // }

さて、一旦不満の洗い出しが完了しました。
実装に移りましょう。

🔧 実装

実装.1 エンドポイントとレスポンスの型を定義する。

今回求めていたのは基本的には開発者に都度裁量を求められてしまうということでした。
ですので、まずはエンドポイントとそのエンドポイントと1対1になるように型定義を行います。

  type Endpoints = {
    list: {
      piyo: Piyo;
    }
    object: {
      hoge: Hoge;
    }
  }

microCMSのAPIにはリスト型とオブジェクト型、2種類のAPIを作成することができます。
それによってmicrocms-js-sdkで呼び出しを行う関数が異なる為、棲み分けを行う意味でもlistobjectというkeyで分けました。
今回は主に、listを取り扱っていきます。

実装.2 MicroCMSQueriesの型定義を改修する。

fieldsの型定義がstring[]|stringであると前述しました。
これをどうにかする為に定義を改修しましょう。

  import { MicroCMSQueries, MicroCMSListContent } from 'microcms-js-sdk'

  type GetListQueries<F> = {
    fields?: F[];
  } & Omit<MicroCMSQueries, 'fields'>;

  const queries: GetListQueries<keyof (Piyo & MicroCMSListContent)> = {
    fields: ['id', 'title', 'publishedAt'] // type-safe
  }

今回は、GetListQueriesという型名で進めます。
MicroCMSQueriesからfieldsを除外して、新しく定義したfieldsを当て込んでいます。
こうすることで、MicroCMSQueriesに更新があっても対応がしやすくなると踏んで今回はこのようにしました。

実装.3 GetListRequestの型定義を改修する。

getListを実行する上で必要な型定義GetListRequestというのがmicrocms-js-sdkにはあります。
既存のものだと、endpointqueriesなどの用途が合わないので改修を施します。

  type ClientEndPoints = {
    list?: {
      [key: string]: any;
    };
    object?: {
      [key: string]: any;
    };
  };
  
  type GetListRequest<
    T extends ClientEndPoints,
    E extends keyof T['list'],
    C extends T['list'][E],
    F extends keyof C
  > = {
    endpoint: E;
    queries?: GetListQueries<F>;
  } & Omit<_GetListRequest, 'endpoint' | 'queries'>;

やたらとジェネリクスの引数が多くなってしまっていますが、クライアントを立ち上げる際に引数を渡したい都合上、継承を施したいがために渡しています。後で効いてきます。

実装.4 MicroCMSListResponseの型定義を改修する。

現在のMicroCMSListResponseの型定義を見てみます。
https://github.com/microcmsio/microcms-js-sdk/blob/f130f3d67cbc727ffc831fd77d19c8760ca4603c/src/types.ts#L61-L66

contents: (T & MicroCMSListContent)[];となっていますね。このままではせっかくfieldsを型安全にしたけれどレスポンスがいい感じに帰ってきてくれません...改修します!

  import { MicroCMSListResponse } from 'microcms-js-sdk'

  type GetListResponse<C, F extends keyof C> = {
    contents: Pick<C, F>[];
    totalCount: number;
    offset: number;
    limit: number;
  } & Omit<MicroCMSListResponse<C>, 'contents'>;

これにより、指定した要素をレスポンスとして返してもらえるような型になりました。

実装.5 microcms-js-sdkをラップしていく

ここまででgetListを型安全に扱う上で必要な型定義が揃いました。
ここからは実際にmicrocms-js-sdkをラッピングして型安全化していきます。

craeteClientをラップしていきます。

client.ts
  import { 
    createClient as _createClient,
    MicroCMSClient
  } from 'microcms-js-sdk';

  const createClient = (clientArg: MicroCMSClient) => {
    const _client = _createClient(clientArg);

    const getList = _client.getList;

    return { getList };
  };
  
  const client = createClient({
    serviceDomain: 'hoge',
    apiKey: 'xxxxxx'
  });

次に、エンドポイントの指定とレスポンスを型安全にカスタマイズしていきます。

client.ts
  import { 
    createClient as _createClient,
-   MicroCMSClient
+   MicroCMSClient,
+   MicroCMSListContent
  } from 'microcms-js-sdk';
  
+ type Endpoints = {
+   list: {
+     piyo: Piyo;
+   }
+ }

- export const createClient = (clientArg: MicroCMSClient) => {
+ export const createClient = <T extends Endpoints = Endpoints>(
+   clientArg: MicroCMSClient
+ ) => {
    const _client = _createClient(clientArg);

-   const getList = _client.getList;
+   const getList = <
+     E extends keyof T['list'],
+     C extends T['list'][E] & MicroCMSListContent
+   >({
+     endpoint
+   }: {
+     endpoint: E;
+   }) => {    
+     return _client.getList<C>({
+       endpoint: String(endpoint)
+     });
+   };

    return { getList };
  };
  
  const client = createClient({
    serviceDomain: 'hoge',
    apiKey: 'xxxxxx'
  });
  client.getList({
    endpoint: 'piyo' // type-safe
  })
  // Response Types:
  // {
  //   contents: (Piyo & MicroCMSListContent)[];
  //   totalcunt: number;
  //   offset: number;
  //   limit: number;
  // }

無事エンドポイントと連動してレスポンスの値を取得することができました。

次に、queries.fieldsに型保管を効かせて、レスポンスの型定義に連動されるようにします。

client.ts
  import { 
    createClient as _createClient,
    MicroCMSClient,
    MicroCMSListContent
  } from 'microcms-js-sdk';
+ import {
+ 
+ } from './type'
  
  type Endpoints = {
    list: {
      piyo: Piyo;
    }
  }
  
+ // GetListQueries を MicroCMSQueries にパースする。
+ const queriesParser = <T>(queries: GetListQueries<T>): MicroCMSQueries => {
+   return { ...queries, fields: queries.fields?.map((v) => String(v)) };
+ };
+
  export const createClient = <T extends Endpoints = Endpoints>(
    clientArg: MicroCMSClient
  ) => {
    const _client = _createClient(clientArg);

    const getList = <
      E extends keyof T['list'],
-     C extends T['list'][E] & MicroCMSListContent
+     C extends T['list'][E] & MicroCMSListContent,
+     F extends keyof C
    >({
-     endpoint
+     endpoint,
+     queries = {},
+     ...arg
-   }: {
-     endpoint: E;
-   }) => {
-     return _client.getList<C>({
+   }: GetListRequest<T, E, C, F>): Promise<GetListResponse<C, F>> => {
+     return _client.getList({
-       endpoint: String(endpoint)
+       endpoint: String(endpoint),	
+       queries: queriesParser(queries),
+       ...arg
      });
    };

    return { getList };
  };
  
  const client = createClient({
    serviceDomain: 'hoge',
    apiKey: 'xxxxxx'
  });
  client.getList({
    endpoint: 'piyo',
    queries: {
      fields: ['id', 'title', 'publishedAt'] // type-safe
    }
  });
  // Response Types:
  // {
  //   contents: Pick<Piyo & MicroCMSListContent, 'id'|'title'|'publishAt'>[];
  //   totalcunt: number;
  //   offset: number;
  //   limit: number;
  // }

fieldsが保管が効くようになり、指定した項目によってレスポンスが同期的に切り替わるようになりました。

あとは、残りのgetListDetailgetObjectなどを同様にラップしていけば、型安全なmicrocms-js-sdkの出来上がりです!

📢 microcms-ts-sdkの紹介

そして、今回実施した型安全な対応を施したSDKをnpmパッケージとして公開しました!!🎉

https://www.npmjs.com/package/microcms-ts-sdk

  • getList
  • getListDetail
  • getObject
  • create
  • update
  • delete

などの関数にも対応していますので、興味がある方...是非ご利用ください!!
(GitHubにstarつけてくれたらやる気につながります。。)

具体的な利用方法についてはREADMEを参照ください。

https://github.com/tsuki-lab/microcms-ts-sdk#readme

おわりに

今回は、個人的に感じていた課題感を無事解消することができました!!
皆さんの型安全ライフに貢献できたら幸いです。

Twitterに普段は生息しておりますのでよろしければこちらもお願いします!
それではこれにて

chot Inc. tech blog

Discussion