📘

Next.jsのAPI Routesで共通のエラーをハンドリングする

2022/01/10に公開1

本記事で利用したパッケージのバージョン

  • typescript: 4.5.2
  • next: 12.0.7
  • zod: 3.11.6

やりたかったこと

API Routes 中の処理で、ルート毎に個別にエラー処理を書きたくない定型的なパターンがあってそれをまとめて catch して処理したかった。
例えば、私の場合は query のバリデーションでした。
具体的には以下のような API ルートがあります。

pages/api/users/[id].ts
import { NextApiResponse, NextApiRequest } from "next";
import { z } from "zod";

const querySchema = z.object({
  id: z
    .string()
    .refine((v) => {
      return !isNaN(Number(v));
    })
    .transform((v) => Number(v)),
});

const getHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  const result = querySchema.safeParse(req.query);
  if (!result.success) {
    return res.status(400).json({ error: { messsage: "クエリが不正です" } });
  }
  const { id } = result.data;
  const user = await getUser(id);
  if (!user) {
    return res
      .status(404)
      .json({ error: { message: "ユーザーが見つかりません" } });
  }
  return res.status(200).json({ data: user });
};

export default async (req: NextApiRequest, res: NextApiResponse) => {
  switch (req.method) {
    case "GET":
      getHandler(req, res);
      break;
    case "PATCH":
      patchHandler(req, res);
      break;
    default:
      res.status(405).json({
        error: {
          message: `Method ${req.method} Not Allowed`,
          statusCode: 405,
        },
      });
  }
};

zodを利用して、req.query のバリデーションをしています。 zod 自体の詳細な説明は省かせていただきますが、ここでは 「querySchema.safeParse() で渡されたものが { id: string } 型のオブジェクトで、numberに変換可能であれば { id: number } に変換して返す。上の条件を満たさなければエラーを返す(それを result.success で判別できるように型定義されている)」とだけ認識していただければ問題ないと思います。

query のバリデーションは(ユーザーが自由に入力できる場合を除いて)個別にハンドリングする必要を感じなかったのでルート毎に毎回このような処理を書くのが億劫でした。また、getUser()部分でも(想定外の)エラーが throw される可能性があって、真面目にやるならそれも try{}catch{} してハンドリングしなければいけません。

このように、個別にハンドリングしたくない(≒ 想定外の)エラーはどこかで catch してまとめて処理したくなったという状況です。

最終的な実装

エラーを受け取ってそれに応じて res を返すハンドラーを作成。

helpers/apiRoute/errorHandler.ts
export const errorHandler = (error: any, res: NextApiResponse) => {
  // API Routes内でthrowされるエラークラスを決めておいて、ここで判別してハンドリングする
  if (error instanceof RequestQueryError) {
    return res.status(error.statusCode).json({ error: error.serialize() });
  }
  if (error instanceof RequestBodyError) {
    return res.status(error.statusCode).json({ error: error.serialize() });
  }

  // その他予期してないエラーに対する処理
  return res
    .status(500)
    .json({ error: { message: error.message, statusCode: 500 } });
};

ここで RequestQueryError 等は Error を拡張したクラス で、決まったタイミングで throw されるようにしておくことでここで共通処理できるようにします。
具体的には今回は以下のような関数を作っておいてルート側で使うようにしました。

helpers/apiRoute/validateQuery.ts
import { z, ZodRawShape } from "zod";

export const validateQuery = <
  T extends ZodRawShape,
  Q extends Record<string, unknown>
>(
  querySchema: z.ZodObject<T>,
  query: Q
) => {
  const result = querySchema.safeParse(query);
  if (!result.success) {
    const {
      error: { stack },
    } = result;
    throw new RequestQueryError({
      statusCode: 400,
      message: "クエリが不正",
      name: "InvalidQueryParameter",
      stack,
    });
  }
  return result.data;
};

RequestBodyError クラス は上とほぼ同じで、 req.body のバリデーションの時に throw するエラーです。

次に各ルートでハンドラーが実行されエラーが throw されたときにそれを catch して errorHandler に渡すようにするために、ハンドラーのラッパー関数を作ります。

helpers/apiRoute/apiHandler.ts
const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;

type HttpMethod = typeof httpMethods[number];

const isHttpMethod = (method: string): method is HttpMethod => {
  return httpMethods.some((m) => m === method);
};

type Handlers = {
  [key in HttpMethod]?: NextApiHandler;
};

export const apiHandler = (handlers: Handlers) => {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    const { method } = req;

    if (!method || !isHttpMethod(method)) {
      return res.status(405).json({
        error: {
          message: `Method ${req.method} Not Allowed`,
          statusCode: 405,
        },
      });
    }

    const handler = handlers[method];

    if (!handler) {
      return res.status(405).json({
        error: {
          message: `Method ${req.method} Not Allowed`,
          statusCode: 405,
        },
      });
    }

    // 共通でなにかやりたいことがあればここでやることもできる(Next v12 であれば _middleware でもできそうだけど...)
    try {
      await handler(req, res);
    } catch (err) {
      errorHandler(err, res);
    }
  };
};

最初のコードで、ルート内で switch (req.method) で method に応じた処理が定義されているかを確認していたのもここに持ってきています。

これを使ったルート側は次のようになります。

pages/api/users/[id].ts
// models/user.ts みたいなところで定義しておく
// const updateUserSchema = z.object({
//   bio: z.string().max(20, 'bio は20文字以内で入力してください').optional(),
// });
// { bio?: string} 型で、20字以下であればvalid

const querySchema = z.object({
  // ...
});

const getHandler: NextApiHandler = async (req, res) => {
  const { id } = validateQuery(querySchema, req.query);
  const user = await getUser(id);
  // ...
};

const patchHandler: NextApiHandler = async (req, res) => {
  const { id } = validateQuery(querySchema, req.query);
  const { bio } = validateBody(updateUserSchema, req.body);
  const updatedUser = await updateUser(id, { bio });
  // ...
};

export default apiHandler({
  GET: getHandler,
  PATCH: patchHandler,
});

これで最初のコードよりスッキリして、定型的なエラー処理を各 API ルートに書かなくて良くなりました。
getUser() 等のデータ取得処理においても個別にハンドリングが必要ないエラーについては同様に throw して errorHandler で処理するようにすれば良さそうです。

Discussion

melodycluemelodyclue

お聞きしたいのですが、「最終的な実装」の「 その他予期してないエラーに対する処理」の部分で、そのまま未知のエラーメッセージを返すのは実際にはセキュリティリスクがあるのではないかと思いましたがどうでしょうか?