🔥

Next.jsでaxiosへの型付けとエディタ補完をする

2023/08/25に公開

はじめに

フロントエンドからAPIをコールする時に、型やエディタでの補完がなくて辛いなと思いました。良さげなライブラリは見つけられなかったので、以下の記事を参考に補完を効かせつつ、パラメータなどに型が付くようにします。

https://zenn.dev/takepepe/articles/nextjs-typesafe-api-routes

https://zenn.dev/sum0/articles/8e903ed05ba681

前提

  • APIのコールにはfetchではなくaxoisを使う
  • バックエンドのAPIサーバーはTypeScript以外
  • OpenAPIを使っていない

環境

  • macOS: 13.5
  • Next.js: 13.4

動作サンプル

以下はPyCharmで動かした時の補完の挙動の例です。パスに関する補完が効いていることがわかります。

また、パラメータに関しても同様に補完が効きます。

パラメータが存在しない場合にはエラーも出してくれます。

コード

コードは以下のリポジトリに置いてあります。適宜参考にしてください。

https://github.com/gsy0911/zenn-nextjs-routes-types

準備

特に意味はないですがアプリの作成から行います。AppRouterは利用せずに、PagesRouterを利用します。

$ npx create-next-app@latest
Need to install the following packages:
  create-next-app@13.4.19
Ok to proceed? (y) y
✔ What is your project named? … zenn-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes

アプリの作成が終わったら必要なライブラリをインストールします。

# それぞれ必要なパッケージをインストール
$ npm install type-fest axios swr query-string
$ npm install prettier -D

環境変数に型をつけるため以下のファイルをディレクトリ直下に作成します。こうすることで、環境変数を参照するときにnullかどうかを考慮しなくてよくなります。

globals.d.ts
declare namespace NodeJS {
  // 環境変数名の定義
  interface ProcessEnv {
    readonly NEXT_PUBLIC_BACKEND_API_ENDPOINT: string;
  }
}

そして、以下の環境変数ファイルを作成します。

.env.local
NEXT_PUBLIC_BACKEND_API_ENDPOINT=http://localhost:3000

最後に、API Routesを利用してあらかじめ対応する検証用のAPIを作成します。pages/api以下にファイルやフォルダを作成すると、簡易的なAPIサーバーが作れます。この例では、http://localhost:3000/api/user にブラウザからアクセスすると、{ status: "success", id: "1", name: "Alice" }の結果を取得できます。

pages/api/user.ts
import type { NextApiRequest, NextApiResponse } from "next";

type Data = {
  status: string; 
  id: string;
  name: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>,
) {
  if (req.method === "GET") {
    res.status(200).json({ status: "success", id: "1", name: "Alice" });
  } else if (req.method === "POST") {
    res.status(200).json({ status: "success", id: "2", name: "Bob" });
  } else if (req.method === "PUT") {
    res.status(200).json({ status: "success", id: "3", name: "Eve" });
  } else if (req.method === "DELETE") {
    res.status(200).json({ status: "success", id: "4", name: "Mallory" });
  }
}

テスト用の様々なパスのリクエストを受け付けられるように、以下のファイルを作成します。この例では、http://localhost:3000/api/v1/a/b/chttp://localhost:3000/api/v1/abc/def にアクセスすると、{ status: "success", id: "1", name: "Alice" }の結果を取得できます。

詳しくは以下の公式ドキュメントを確認してください。

https://nextjs.org/docs/pages/building-your-application/routing/api-routes#catch-all-api-routes

pages/api/v1/[...slug].ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  const slugs = req.query.slug || ["/"];
  const url: string =
    typeof slugs === "string" ? `/${slugs}` : `/${slugs.join("/")}`;
  console.log(
    `method: ${req.method}, query: ${JSON.stringify(req.query)}, url: ${url}`,
  );
  if (req.method === "GET") {
    res.status(200).json({ status: "success", id: "1", name: "Alice" });
  } else if (req.method === "POST") {
    res.status(200).json({ status: "success", id: "2", name: "Bob" });
  } else if (req.method === "PUT") {
    res.status(200).json({ status: "success", id: "3", name: "Eve" });
  } else if (req.method === "DELETE") {
    res.status(200).json({ status: "success", id: "4", name: "Mallory" });
  }
}

これで準備は完了です。これから型を定義していきます。

バックエンドのAPIの型定義

まずバックエンドのAPIの型の定義をします。二度手間感はあるのですが、これをしないと型の補完が効かせられないためです。OpenAPIなどを使っている場合には、この記事(再掲)を読んだ方が幸せになると思います。

以下のAPIの型を定義するためのtypeやinterfaceを作成します。

common/types/api.ts
import type { NextApiRequest, NextApiResponse } from "next";
export interface Data<T> {
  data: T;
}

export interface Error {
  error: {
    httpStatus: number;
    message: string;
  };
}

export type ApiParams<P = any, Q = any, B = any, R = any> = (
  // 正確には`path`をOmitする必要はないですが、今後のことも考えてこうしています。
  req: Omit<NextApiRequest, "body" | "query" | "path"> & {
    query: Partial<Q>;
    body?: B;
    path?: P;
  },
  res: NextApiResponse<Data<R> | Error>,
) => void | Promise<void>;

export type ExtractReqQuery<T> = T extends ApiParams<any, infer I, any, any>
  ? I
  : never;
export type ExtractReqData<T> = T extends ApiParams<any, any, infer I, any>
  ? I
  : never;
export type ExtractResData<T> = T extends ApiParams<any, any, any, infer I>
  ? I
  : never;
export type ExtractPathParams<T> = T extends ApiParams<infer I, any, any, any>
  ? I
  : never;

ここで共通の型を定義しています。

export interface Data<T> {
  data: T;
}
export interface Error {
  error: {
    httpStatus: number;
    message: string;
  };
}

そして、ここでAPIのパラメータをそれぞれ定義できるようにするための型を定義しています。

export type ApiParams<P = any, Q = any, B = any, R = any> = (
  req: Omit<NextApiRequest, "body" | "query" | "path"> & {
    query: Partial<Q>;
    body?: B;
    path?: P;
  },
  res: NextApiResponse<Data<R> | Error>,
) => void | Promise<void>;

// 後述しますが以下のような利用を想定しています。
// export type GetUserHandler = ApiParams<
//   {},  // パスパラメータ
//   { id: string },  // リクエストquery
//   {},  // リクエストbody
//   { status: string; name: string }  // レスポンスbody
// >;

次に、上で定義したApiParamsを使ってバックエンドのAPIの型定義を書きます。例として、GET, POST, PUT, DELETEの定義を書いてみます。

lib/api/types.ts
import { ApiParams } from "@/common/types/api";

type GetUserHandler = ApiParams<
  {},
  { id: string },
  {},
  { status: string; id: string; name: string }
>;

type PostUserHandler = ApiParams<
  {},
  {},
  { name: string },
  { status: string; id: string; name: string }
>;

type PutUserHandler = ApiParams<
  {},
  {},
  { id: string; name: string },
  { status: string; id: string; name: string }
>;

type DeleteUserHandler = ApiParams<
  {},
  {},
  { id: string },
  { status: string; id: string; name: string }
>;

type GetUserPathParamHandler = ApiParams<
  { userId: string },
  { id: string },
  {},
  { status: string; id: string; name: string }
>;

export interface paths {
  // これらのメソッドは、`/user`というパスに対応しているという想定
  "/user": {
    GET: GetUserHandler;
    POST: PostUserHandler;
    PUT: PutUserHandler;
    DELETE: DeleteUserHandler;
  };
  // 補完の例として適当なパスを追加(/v1/[...slug].tsで受ける)
  "/v1/user/book": {
    GET: GetUserHandler;
  };
  "/v1/user/payment": {
    GET: GetUserHandler;
  };
  "/v1/user/balance": {
    GET: GetUserHandler;
  };
  "/v1/user/{userId}/balance": {
    GET: GetUserPathParamHandler;
  };
}

axiosのクライアント作成

https://zenn.dev/sutamac/articles/27246dfe1b5a8e

に書かれているaxiosのApiClientを利用します。

lib/api/clients.ts
import axios from "axios";
// 共通ヘッダー
const headers = {
  "Content-Type": "application/json",
};
// axiosの初期設定
export const BackendApiClient = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_BACKEND_API_ENDPOINT}/api`,
  headers,
});

// レスポンスのエラー判定処理
BackendApiClient.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    console.log(error);
    switch (error?.response?.status) {
      case 401:
        break;
      case 404:
        break;
      default:
        console.log("== internal server error");
    }

    const errorMessage = (error.response?.data?.message || "").split(",");
    throw new Error(errorMessage);
  },
);

初期設定のURLは準備のところで設定した環境変数を用います。このApiClientを利用することで毎回設定するURLheadersの定義が1回で済みます。

// axiosの初期設定
export const BackendApiClient = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_BACKEND_API_ENDPOINT}/api`,
  headers,
});

APIの型補完を効かせる

フロントのコーディングをする際の補完を効かせて行きます。
この記事(再掲)の内容がほとんどそのまま出てきます。

まずは、APIを呼ぶ関数の補完を効かせるための型を定義します。

lib/api/schemaHelper.ts
import { paths } from "./types";
import { UnionToIntersection, Get } from "type-fest";
import {
  ExtractPathParams,
  ExtractReqQuery,
  ExtractReqData,
  ExtractResData,
} from "@/common/types/api";

export type UrlPaths = keyof paths;
export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;
export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
  keyof UnionToIntersection<paths[Path]>;

export type RequestPathParameters<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = ExtractPathParams<Get<paths, `${Path}.${Method}`>>;

export type RequestParameters<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = ExtractReqQuery<Get<paths, `${Path}.${Method}`>>;

export type RequestData<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = ExtractReqData<Get<paths, `${Path}.${Method}`>>;

export type ResponseData<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = ExtractResData<Get<paths, `${Path}.${Method}`>>;

以下の箇所でpathsのキーであるAPIのパスと、メソッドの対応関係を取得します。

export type UrlPaths = keyof paths;
export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;
export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
  keyof UnionToIntersection<paths[Path]>;

そして、PathMethodをキーに指定してpathsからGet<>で対応するAPIを取得し、さらにそのAPIのinterfaceからExtractReqQuery<>でリクエストの型を取得しています。Get<>type-festで、ExtractReqQuery<>は上で定義したユーティリティです。

export type RequestParameters<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = ExtractReqQuery<Get<paths, `${Path}.${Method}`>>;

最後にaxiosswrを利用するための関数を記述します。

lib/api/hooks.ts
import { AxiosError, AxiosResponse, AxiosRequestConfig } from "axios";
import useSWR from "swr";
import {
  HttpMethods,
  HttpMethodsFilteredByPath,
  RequestPathParameters,
  RequestData,
  RequestParameters,
  ResponseData,
  UrlPaths,
} from "@/lib/api/schemaHelper";
import { BackendApiClient } from "./clients";

export type AxiosConfigWrapper<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = {
  url: Path;
  method: Method & HttpMethodsFilteredByPath<Path>;
  paths: RequestPathParameters<Path, Method>;
  params?: RequestParameters<Path, Method>;
  data?: RequestData<Path, Method>;
};

export function request<Path extends UrlPaths, Method extends HttpMethods>(
  config: AxiosConfigWrapper<Path, Method>,
) {
  const { url, paths, ...baseConfig } = config;
  const requestConfig: AxiosRequestConfig = {
    ...baseConfig,
    url: Object.entries(paths ?? {}).reduce(
      (previous, [key, value]) =>
        previous.replace(new RegExp(`\\{${key}\\}`), String(value)),
      url as string,
    ),
  };
  return BackendApiClient.request<
    ResponseData<Path, Method>,
    AxiosResponse<ResponseData<Path, Method>>,
    AxiosConfigWrapper<Path, Method>["data"]
  >(requestConfig);
}

const fetcher = <Path extends UrlPaths, Method extends HttpMethods>(
  config: AxiosConfigWrapper<Path, Method>,
) => {
  return request<Path, Method>(config).then((res) => res.data);
};

export const useAppSWR = <Path extends UrlPaths, Method extends HttpMethods>(
  config: AxiosConfigWrapper<Path, Method>,
) => useSWR<ResponseData<Path, Method>, AxiosError>(config, fetcher);

これで完成しました。ただ、requestの方はカスタムフックにはなっていないので、以下の記事などを参考に必要に応じてカスタムフックにすると良いかと思います。

https://qiita.com/mamimami0709/items/603c6ea9f9bfa68461f9

動作確認

作成したSWRのフックを使ってGETリクエストを行ってみます。パスパラメータの{userId}も引数と対応させたURLにアクセスしています。

pages/index.tsx
// (...前略)
export default function Home() {
  const { data, mutate: reFetch } = useAppSWR({
    url: "/v1/user/{userId}/balance",
    method: "GET",
    params: { id: "any" },
    paths: { userId: "1" },
  });
// (...後略)

そのほかのPOSTなどのリクストも同じような結果を得られます。

pages/index.tsx
// (...前略)
        <button
          className="(省略)"
          rel="noopener noreferrer"
          onClick={() => {
            request({
              url: "/user",
              method: "POST",
              data: {
                name: "new-name",
              },
              paths: {},
            }).then((res) => console.log(res.data.name));
          }}
        >
// (...後略)

おわりに

フロントエンドからバックエンドのAPIを実装する際に、型を付けたりエディタ補完が効くようになって安心できました。

そのほかの参考記事

https://omkz.net/nextjs-swr-post/

GitHubで編集を提案

Discussion