💡

ReactアプリケーションにおけるAPI関連のプラクティスの紹介

2022/10/19に公開約15,000字

現在開発に携わっているプロジェクトで、OpenAPIを用いたAPIクライアントと型定義の自動生成、 生成されたコードの利用方法を含めた実装方式の策定などを行い、実用レベルまで整備することができたので、その概要についてまとめます。

アプリケーションはフロントエンドはNext.js、バックエンドはNestJSで、それぞれTypeScriptで記述されています。
基本的には認証されたユーザのみが利用するアプリケーションであり、パフォーマンスへの要求も大きくないため、ほとんどのページでSSG(static site generation)やSSR(server-side rendering)を行っておらず、 SWRを通してAPIコールでデータを取得して画面を表示するCSR(client-side rendering)を行っています。

基本方針

OpenAPIによるAPI定義とそれによって生成されたコードを常に正とする

既にREST指向で実装されたAPIが存在する状況であったため、API定義を表現する形式としてOpenAPI Specificationを選択しました。
もしGraphQLを採用している、もしくは採用を予定しているのであれば、そのスキーマ定義などが同様の役割を果たすでしょう。

API定義はフロントエンドとバックエンドのインタフェースです。それぞれが互いの実装の詳細を知ることなく、インタフェースに対して実装を行えば、アプリケーションが適切に動作するという状況が理想です。
そのため、フロントエンドとバックエンドにまたがるあらゆる機能の追加・変更は、まずAPI定義から行い、それに基づいて生成されたコードに対して実装を行います。

フロントエンドのコンポーネントやロジックは生成された型定義に直接依存しない

API定義に基づいて生成された型定義は、あくまでもフロントエンドとバックエンドのインタフェースに対するものです。生成された型定義をフロントエンドのコンポーネントやロジックが直接参照することは、APIとそれらが密結合となることを意味するため、行うべきではありません。
フロントエンドは自身で型定義で行い、コンポーネントやロジックはそれを利用します。
もちろん、多くの場合はAPI定義に基づいて生成された型定義と、フロントエンドを実装する上で導かれる型定義は一致することが多いでしょう。そのため、フロントエンドの型定義を提供するコードが、生成された型定義をそのままエクスポートすることは許容されます。

これはバックエンドでも同様であり、ドメインに属するコードは生成された型定義を参照してはいけません。APIはあくまでもフロントエンドとやりとりするためのインタフェースの1つであるためです。

フロントエンドのコンポーネントは生成されたAPIクライアントを直接利用しない

生成されたAPIクライアントは多くの場合、APIコールにおいて必要とされる全てのパラメータを要求するメソッドを提供します。そのパラメータの中には、トークンなどグローバルで管理されるであろう値も含まれます。
コンポーネントが自身の挙動に関係のないグローバルな値を直接参照したり、propsなどで引き回すことは好ましくありません。
また、 SWRが管理するキャッシュもグローバルな値であるため、コンポーネントが直接それらを操作する実装は避けるべきでしょう。
APIを呼び出す機能を提供するフックを実装し、コンポーネントはグローバルな値に関心を持つことなくそれを利用できるようにします。
フックは必要であればその内部でトークンなどのグローバルな値を保持してAPI呼び出しを行います。操作系APIのフックであれば、APIコールのレスポンスに応じたキャッシュ制御も行います。

フックを介したAPIの呼び出しは、ライブラリの更新・変更、自動生成されたAPIクライアントへの段階的な移行といったシーンで、影響範囲を局所化する効果ももたらします。

プラクティス

APIの命名や設計の規約

RESTを基本としたAPI設計を前提とした規約です。

リソース表現

OpenAPIの operationId が自動生成されるAPIクライアントのメソッド名となるため、利用時に混乱を招かないよう命名規約を定めています。

  • [GET] /{resources} - list{Resource}
  • [POST] /{resources} - create{Resource}
  • [GET] /{resource}/:{resource}Id - read{Resource}
  • [PATCH] /{resources}/:{resource}Id - update{Resource}
  • [DELETE] /{resources}:{resource}Id - delete{Resource}

特にリソースの listread について、仮に get を用いた際に起こるであろう名称による混乱を予防することを目的としています。

特定の関心に属していることを示すためのネームスペースは許容されます。重複が発生する場合はネームスペースを命名に含めます。

  • [GET] /{namespace}/{resources} - list{Resources}On{Namespace}

リソースの list に対するフィルタリングは基本的にクエリストリングで表現しますが、 undefined を許容しないパラメータをとる場合はパスとして表現します。

  • [GET] /{resources}/:{condition} - list{Resource}By{Condition}

ネストされたリソースの場合は次のように表現します。

  • [GET] /{resources}/:{resource}Id/{sub-resources} - list{SubResource}OwnedBy{Resource}

リソースの表現でないアクションは [POST] を用いて動詞で表現します。

  • 例) [POST] /payment/products/{productId}/checkout - checkoutProduct

APIを呼び出すコンテキストにおいて必ず1つとなるものは単数系で表現します。必要に応じて判別可能な命名を行います。

  • 例) [GET] /profile - readMyProfile

特定のプロパティを要求する際のパラメータ

パフォーマンスなどの観点から、パラメータが与えられた際に特定の項目をレスポンスに含める、といった挙動を表現する場合、クエリストリングでプロパティ名を配列で受け付ける include または embed を用います。 include はリソースの特定のプロパティに対して、 embed は関連リソースに対して用います。

レスポンスのフォーマット

createreadupdate ではリソースを返します。
list の場合は以下のようなenvelopeを用いて、必要に応じてページネーションなどの情報を含めます。
フロントエンドではこのオブジェクトをcollectionと呼称し、通常の配列などと区別しています。

{
  items: [...],
}

Himenon/openapi-typescript-code-generatorを用いたコード生成

OpenAPIからAPIクライアントと型定義を生成するために、Himenon/openapi-typescript-code-generatorを利用しています。
このライブラリの選定理由は以下となります。

  • OpenAPI v3.0.xに準拠している
  • APIクライアントと型定義を別ファイルに分けるなど、出力を細かく制御できる
  • 実際にAPIコールを実行するクラアインをDI形式で実装でき、特定のライブラリに依存しない

このライブラリはTypeScript ASTを利用した実装で拡張も可能ですが、デフォルトで提供されるテンプレートが利用しやすく、現時点ではそのまま利用しています。

生成される Client のstaticメソッドのサンプルをいくつか示します。

public async readTask(params: Params$readTask, option?: RequestOption): Promise<Response$readTask$Status$200["application/json"]> {
    const url = this.baseUrl + `/tasks/${params.parameter.taskId}`;
    const headers = {
        Accept: "application/json"
    };
    return this.apiClient.request("GET", url, headers, undefined, undefined, option);
}
public async updateTask(params: Params$updateTask, option?: RequestOption): Promise<Response$updateTask$Status$200["application/json"]> {
    const url = this.baseUrl + `/tasks/${params.parameter.taskId}`;
    const headers = {
        "Content-Type": "application/json",
        Accept: "application/json",
        "CSRF-Token": params.parameter["CSRF-Token"]
    };
    return this.apiClient.request("PATCH", url, headers, params.requestBody, undefined, option);
}

引数のparamsはパスパラメータ、クエリストリング、リクエストボディをプロパティとするオブジェクトを要求するため使いやすく、変更時の影響も少なくなります。

実際にAPIコールを行う request を持つ apiClientClient 生成時のコンストラクタとして baseUrl とともに渡すことで、DIを実現しています。

プロジェクトでは yarn workspace を用いたモノレポで管理されており、自動生成のためのスクリプトと docs/openapi.yaml から出力されたコードを管理する openapi-code-gen というパッケージを用意し、 packages/openapi-code-gen/dist に出力されたコードを他のパッケージが利用するという形式をとっています。これはモノレポかつ、フロントエンドとバックエンドが1対1であることからとった構成です。
例えばポリレポであったり、フロントエンドまたはバックエンドが複数あるようなプロジェクトでは、バックエンドが自身の提供するAPIの定義を管理し、フロントエンドはそのAPI定義からコードの生成を行うといった構成をとる方が自然かもしれません。

生成された Client の利用

apiClient の実装

apiClient は次のようなインタフェースであることを要求されます。

export interface ApiClient<RequestOption> {
    request: <T = SuccessResponses>(httpMethod: HttpMethod, url: string, headers: ObjectLike | any, requestBody: ObjectLike | any, queryParameters: QueryParameters | undefined, options?: RequestOption) => Promise<T>;
}

アプリケーションでは request を関数として定義しており、外部の変数に依存せず、引数から必要な値を組み立てて fetch を用いてAPIコールを実行するというシンプルな実装としています。
クエリストリングの組み立てには、配列を要求するクエリパラメータを適切に扱うため、query-stringを利用しています。

なお、上記のインタフェースが示すようにHimenon/openapi-typescript-code-generatorのデフォルトのテンプレートはエラー時のレスポンスやheadersの値を表現していません。
現在の実装では fetch 実行時にエラーが発生した場合はそのエラーを、 レスポンスエラーの場合はレスポンスそのものをthrowし、 request では具体的なハンドリングを行わない実装としています。これは、APIコールを実行するクライアントそのものに、アプリケーションに関する知識が入ってしまうことを避けるためです。
現時点ではheadersの値を利用することがないため未実装ですが、headersの値を利用したい場合は、テンプレートの拡張、戻り値に __response__ のようなプロパティを追加して利用する側が response そのものにアクセスできるようにする、headersが必要となるAPIでは生成されたクライアントを用いない、といった選択肢が考えられます。

Client の初期化と利用

Client のインスタンスを生成して変数に代入する configure_app.tsx で呼び出します。
clientを利用する際は、 getClient を用いてその参照を取得します。

let sharedClient: Client<RequestOption> | undefined = undefined;

export const configure: (config: { baseUrl: string }) => void = config => {
  if (
    typeof baseUrl !== "undefined" &&
    baseUrl.length > 0
  ) {
    sharedClient = new Client({ request }, config.baseUrl);
  } else {
    sharedClient = undefined;
  }
};

export const getClient: () => Client<RequestOption> = () => {
  if (typeof sharedClient === "undefined") {
    throw new Error("Require configure");
  }

  return sharedClient;
};

エラーハンドリングのための requestWrapper

前述したように、 requestfetch 実行時にエラーが発生した場合はそのエラーを、レスポンスエラーの場合はレスポンスそのものをthrowします。
利用箇所で都度エラーハンドリングを行うことは冗長であるため、エラーハンドリングのためのラッパー関数を実装しています。

export const requestWrapper: <T, S extends unknown[]>(
  requestFunction: (...args: S) => Promise<T>,
) => (
  ...args: S
) => Promise<{ data: T } | { error: ResponseError }> = requestFunction => {
  return async (...args) => {
    if (typeof sharedClient === "undefined") {
      throw new Error("Require configure");
    }

    try {
      const data = await requestFunction.bind(sharedClient)(...args);
      return { data };
    } catch (thrown) {
      if (thrown instanceof Response) {
        const error = new ResponseError(
          `An error occurred status:${thrown.status} url:${thrown.url}`,
        );
        error.status = thrown.status;
        try {
          error.info = await thrown.json();
        } catch (_) {
          // ignore
        }

        return { error };
      } else if (isFetchError(thrown)) {
        const error = new ResponseError(`An error occurred fetch`);
        error.info = thrown.error;

        return { error };
      } else {
        const error = new ResponseError(`An error occurred`);
        error.info = thrown;

        return { error };
      }
    }
  };
};

requestWrapper の実装サンプルです。アプリケーションではこの中で、Sentryへのログの送信などを行なっています。
throwされたものを判定し、 Error を継承した ResponseError を返します。

const client = getClient();

const result = await requestWrapper(client.readTask)({
  parameter,
});

if ("error" in result) {
  throw result.error;
}

return result.data;

上記は useSWR に渡すfetcher内での requestWrapper の利用例です。
requestWrapper を介することで、errorの型がResponseErrorであることが保証され、かつタイプガードを用いることで呼び出し側に適切なハンドリングを行うことを要求します。
また、この requestWrapper はReactの機能に依存しない関数であるため、 getServerSideProps の中で利用することも可能です。

コンポーネントが利用するフックの実装

方針でも述べたように、コンポーネントがグローバルの状態に関心を持つ必要がないように、APIコールとキャッシュ制御を内部で行うフックを提供します。

読み取り系APIフック

export interface UseReadTaskParams {
  parameter: Parameter$readTask;
  isDisabled?: boolean;
}

export type UseReadTaskValue = SWRResponse<
  Response$readProfile$Status$200["application/json"] | undefined,
  ResponseError
>;

export const getReadTaskKey: (params: {
  csrf: string;
  parameter: Parameter$readTask;
}) => Key = ({ csrf, parameter }) => {
  return {
    path: "/tasks/{taskId}",
    csrf,
    parameter,
  };
};

export interface UseMutateReadTaskParams {
  csrf?: string;
  parameter?: Parameter$readTask;
}

export interface UseMutateReadTaskValue {
  mutateReadTask: BulkMutator<
    Response$readTask$Status$200["application/json"]
  >;
}

export const useMutateReadTask: (
  params: UseMutateReadTaskParams,
) => UseMutateReadTaskValue = ({ csrf, parameter }) => {
  const { cache, mutate } = useSWRConfig();

  const mutateReadTask = useCallback<
    UseMutateReadTaskValue["mutateReadTask"]
  >(
    async (data, options) => {
      if (typeof csrf === "undefined" || typeof parameter === "undefined") {
        return [];
      }

      const matchedKeys: Key[] = [];
      for (const key of (cache as any).keys()) {
        if (
          !swrInternalKeyRegExp.test(key) &&
          /"\/tasks\/{taskId}"/.test(key) &&
          key.includes(parameter.taskId) &&
          key.includes(csrf)
        ) {
          matchedKeys.push(key);
        }
      }

      const mutations = matchedKeys.map(key => mutate(key, data, options));
      return Promise.all(mutations);
    },
    [csrf, parameter, cache, mutate],
  );

  return { mutateReadTask };
};

export const useReadTask: (
  params: UseReadTaskParams,
) => UseReadTaskValue = ({ parameter, isDisabled }) => {
  const csrf = useCsrfToken();

  const key = () => {
    if (!csrf) {
      return null;
    }

    if (isDisabled) {
      return null;
    }

    return getReadTaskKey({ csrf, parameter });
  };

  const fetcher: () => Promise<
    Response$readTask$Status$200["application/json"]
  > = async () => {
    if (!csrf || isDisabled) {
      throw new Error("Never");
    }

    const client = getClient();

    const result = await requestWrapper(client.readTask)({
      parameter,
    });

    if ("error" in result) {
      throw result.error;
    }

    return result.data;
  };

  return useSWR(key, fetcher);
};

読み取り系APIフックの実装の一連のサンプルです。
コンポーネントは useReadTask のみを利用し、 useMutateReadTask は操作系APIフックが利用することを意図しています。

getReadTaskKeyuseSWR のキーを生成する関数です。 parameterinclude や embed が含まれているクエリが実行された後で、それらを含まないクエリが発生した際にそのキャッシュを上書きしないために含めます。 csrf は別のユーザがログインした際に、前のユーザのキャッシュが表示されることを防ぐことを意図して含めています。より安全を期すのであれば、ログアウトの際にSWRのキャッシュを削除するべきでしょう。

useMutateReadTask は、SWRが保持するキャッシュのうち path: "/tasks/{taskId}"parameter.taskIdcsrf が一致するものを全て更新する mutateReadTask を返すフックです。これによって、操作系APIフックは、読み取り系APIフックが現在どのようなパラメータで用いられているかを知ることなく、 mutate を実行することができます。 
この処理はSWRの正規表現から複数のキーを変更するを参考にしています。実験的な機能で将来的に変更される可能性があるものですが、フック内部で生成されたキーを全て保持しておくという実装よりも簡易に実装することができる、という判断のもと利用しています。
このサンプルではバウンドミューテートと同様のインタフェースを持つ関数を返していますが、状況に応じてより制約の強い独自の関数を返すこともできます。

操作系APIフック

export interface UseUpdateTaskParams {
  parameter: OmitCsrfToken<Parameter$updateTask>;
}

export interface UseUpdateTaskValue {
  isMutating: boolean;
  updateTask: (params: {
    values: RequestBody$updateTask["application/json"];
  }) => Promise<
    | { data: Response$updateTask$Status$200["application/json"] }
    | { error: ResponseError }
  >;
}

export const useUpdateTask: (
  params: UseUpdateTaskParams,
) => UseUpdateTaskValue = ({ parameter }) => {
  const csrf = useCsrfToken();
  const { mutateReadTask } = useMutateReadTask({
    csrf,
    parameter,
  });
  const { mutateListTask } = useMutateListTask({
    csrf,
  });

  const [isMutating, setIsMutating] = useState(false);

  const updateTask = useCallback<
    UseUpdateTaskValue["updateTask"]
  >(
    async ({ values }) => {
      setIsMutating(true);

      const client = getClient();

      const result = await requestWrapper(client.updateTask)({
        parameter: {
          "CSRF-Token": csrf || "",
          ...parameter,
        },
        requestBody: values,
      });

      if (csrf && !("error" in result)) {
        const updatedTask = result.data;

        mutateReadTask(currentData => {
          if (typeof currentData === "undefined") {
            return undefined;
          }

          return {
            ...currentData,
            ...updatedTask,
          };
        }, false);

        mutateListTask(currentData => {
          if (typeof currentData === "undefined") {
            return undefined;
          }

          return {
            ...currentData,
            items: currentData.items.map(item => {
              if (item.id !== updatedTask.id) {
                return item;
              }

              return {
                ...item,
                ...updatedTask,
              };
            }),
          };
        }, false);
      }

      setIsMutating(false);

      return result;
    },
    [parameter, csrf, mutateReadTask, mutateListTask],
  );

  return { isMutating, updateTask };
};

操作系APIフックの実装の一連のサンプルです。
UseUpdateTaskParams が示すように、APIコールの際に要求される CSRF-Token を除いたパラメータをフックの引数として要求し、 useCsrfToken をフック内で利用してグローバルで管理されている値を利用しています。
また、前述した読み取り系APIフックが提供するmutateフックを用いて、キーがどのように構成されているかを知ることなく、関連するキャッシュの更新を行なっています。実行すべきmutateの判断は個別に行う必要がありますが、より安全に漏れなくキャッシュの更新を行うことができると考えます。

なお、読み取り系APIフック、更新系APIフックともに、 parameter.taskId で表されるリソースのidをフックの引数として要求しています。
例えば、別のAPIのレスポンスの値に依存する、一覧の中から特定のリソースを更新するといった状況では、読み取り系APIフックの引数の parameter のundefinedを許容する、操作系APIフックの引数ではなく操作を実行する関数の引数でリソースのidを要求する、といった実装とする必要があるでしょう。
リソースのidはルーティング経由で渡されることが多く、より宣言的なコードとなるため、個人的にはフックの引数でリソースのidをとる形を基本とすることが好みではあるのですが、操作系APIフックについては、操作を実行する関数が対象のidを引数でとるという形式に統一しても問題はないでしょう。

最後に

命名から詳細な実装方式まで、様々なレイヤーのプラクティスについて紹介しましたが、重視することは以下の通りです。

  • 命名や設計、実装の一貫性を維持する
  • 依存関係を適切に設計する
  • 責務を明確に設計する

ここで紹介したプラクティスの中で、特定のライブラリに対する実装などの部分は直接的には利用できないかもしれませんが、全体的な考え方は多くのプロジェクトに適用できると考えます。

Discussion

ログインするとコメントできます