💎

Next.js API Routes に Zod を組み込む

2022/10/14に公開
1

バリデーションライブラリである Zod を、Next.js で活用する TIPS の紹介です。筆者が Zod を知り・使い始めたのは、React Hook Form のリゾルバーがきっかけです。ブラウザでバリデーションを行うので、不正な入力値検証を API リクエストが発生する前に実行できます。

この Zod はフロントだけではなく、サーバープロセスでも使用できます。例えば、tRPCZodiosなどに見られるように、サーバーへのリクエスト(入力値)を検証しつつ型推論も解決してくれるソリューションとして注目されています。

Next.js API Routes に Zod を組み込む

Next.js には REST API の実装手段として、API Routes が提供されています。しかし、reqに含まれる入力値検証は自前で用意する必要があります。この入力値検証に Zod を使用されている方も多いのではないでしょうか。今回紹介するのは Zod の入力値バリデーションを行いつつ、API Routes ハンドラーに型推論を適用する方法です。

以下のサンプルにあるwithZod関数がその例です。withZod(ZodSchema, NextApiHandler)のように定義することで、第一引数に定義したスキーマどおりの入力値が、reqの型推論に適用されます。もちろん、バリデーションを通過しない場合 400 レスポンスを返します。

import { withZod } from "@/lib/next/withZod";
import { NextApiHandler } from "next";
import { z } from "zod";

const handleGet = withZod(
  // req.query, req.body に期待するバリデーションスキーマを定義
  z.object({
    query: z.object({ name: z.string() }),
  }),
  async (req, res) => {
    // req の型推論がスキーマどおりとなる
    const name = req.query.name; // const name: string
    res.status(200).json({ message: `Hello ${name}` });
  }
);

const handler: NextApiHandler = async (req, res) => {
  switch (req.method) {
    case "GET":
      return handleGet(req, res);
    default:
      res.status(405).json({ message: "Method Not Allowed" });
      return;
  }
};

export default handler;

withZod 関数の内訳

高階関数であるwithZodは、内部でschema.safeParse(req)を実行しています。第二引数である後続のNextApiHandlerが実行される前に 400 レスポンスを返却しているので、不正な入力値の場合、永続層へのアクセスを未然に防ぐことができます。

import { NextApiRequest, NextApiResponse } from "next";
import { z, ZodSchema } from "zod";

export function withZod<T extends ZodSchema>(
  schema: T,
  next: (
    // NextApiRequest に定義されている曖昧な "query" | "body" 定義を除去し、
    // z.infer でスキーマの型定義を抽出する
    req: Omit<NextApiRequest, "query" | "body"> & z.infer<T>,
    res: NextApiResponse
  ) => unknown | Promise<unknown>
) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    const parsed = schema.safeParse(req);
    if (!parsed.success) {
      // 共通のバリデーションエラーレスポンスとして処理
      res.status(400).json({
        message: "Bad Request",
        issues: JSON.parse(parsed.error.message),
      });
      return;
    }
    return next(req, res);
  };
}

入力値のさらなる検証

冒頭のハンドラー例では、?name=textのリクエストのみ通過するようにスキーマが定義されていました。この入力値検証は、次のようにquerybody両方が満たされているようにもできます。要件にあわせて Zod スキーマを定義すればよいため、後続のNextApiHandler処理がシンプルになります。

const handlePost = withZod(
  z.object({
    query: z.object({ id: z.string().min(8) }),
    body: z.object({ message: z.string().min(1) }),
  }),
  async (req, res) => {
    res
      .status(200)
      .json({ message: `ID: ${req.query.id}, message: ${req.body.message}` });
    // req.query.id, req.body.message は型推論が効いている
  }
);

query の型変換

query の検証には注意が必要です。以下のquery.idは「正の整数」を期待したスキーマですが、req.queryは必ずPartial<{ [key: string]: string | string[] }>になります。そのため、このバリデーションは通過しません。

z.object({
  query: z.object({ id: z.number().positive().int() }),
});

req.query.idが「正の整数」であると判定するためには、以下のようにrefineで判定式を書く必要があります。また、バリデーションが通過したら number 型として扱いたいはずなので、transformで変換しておきます。これで後続のNextApiHandlerではreq.query.idを「正の整数」として扱えるようになりました。

z.object({
  query: z.object({
    id: z
      .string()
      .refine((v) => Number.isInteger(+v) && +v > 0, "Require positive int.")
      .transform((v) => +v),
  }),
});

まとめ

Zod はバリデーション・型推論に特化しているため、アプリケーション設計に組み込むことができます。Next.js は薄いフレームワークですので、Zod をうまく織り交ぜていくと便利です。

Discussion

nullnull

大変参考になる記事でした。ありがとうございます。

一点、気づいた点があったためコメントさせていただきます。
queryの型変換について、記事同様に実装してみてもreq.queryは型のみ変更されていて、値は変換されていないようです。

そのため、以下の実装で対応しました。

return next(merge(req, parsed.data), res); // lodash.merge