😸

Fastify+Zodで入力チェックする際の覚え書き

2024/01/19に公開

公式で紹介されているFastify Type Provider Zodを使用すると、FastifyでZodを使用できる。

サンプルのプロジェクトを作成したので、実際の動作はそちらを参照。
https://github.com/sato-dev1234/fastify-drizzle-example

実装の際に気を付けたことや詰まった点について記載する。

バリデーションスキーマの渡し方

Fastifyでバリデーションを行う場合は、値の渡し方によって実装が異なる。

GETでクエリパラーメータを渡す場合
fastify.get('/the/url', { querystring: { body: validationSchema } }, handler)
POSTでボディを渡す場合
fastify.post('/the/url', { schema: { body: validationSchema } }, handler)

また、型情報も渡せるのでバリデーションスキーマに併せて実装しておくと良い。

GETでクエリパラーメータを渡す場合
request: FastifyRequest<{ Querystring: ValidationSchema }>
POSTでボディを渡す場合
request: FastifyRequest<{ Body: ValidationSchema }>

参考:https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/#validation

クエリパラメータはstring型

idはnumber型を想定しているが、クエリパラメータで渡す場合にはstring型で渡されるので注意すること。

export const primaryKeyForQuery = {
  id: z
    .string()
    .min(1, { message: "IDは必須です。" })
    .transform((val) => parseInt(val, 10))
    .refine((val) => !Number.isNaN(val), {
      message: "数値での入力を期待していますが、文字列が入力されました。",
    }),
};

export const primaryKeyForBody = {
  id: z.number(),
};
  1. 文字列の長さのチェック1文字以上でない場合、必須エラーを発生させる。
  2. parseIntでnumber型に変換する
  3. 数値じゃない場合は、エラーを発生させる。

スキーマと型の宣言

z.object

バリデーションスキーマはオブジェクト型で渡すため、z.objectを使用する。

OK
const userInsertSchema = z.object({
  firstName: z.string().max(256).min(1),
  lastName: z.string().max(256).min(1),
});
NG
const userInsertSchema = {
  firstName: z.string().max(256).min(1),
  lastName: z.string().max(256).min(1),
};

z.infer

バリデーションスキーマのTypescriptの型情報をわたす場合はz.inferを使用すること。

OK
type UserInsertSchema = z.infer<typeof userInsertSchema>;
NG
type UserInsertSchema = typeof userInsertSchema;

Nullableな値

zodはデフォルトでNOT NULLのチェックが行われる。値なしには、nullが設定されるケースと値自体が設定されない2パターンが考えられるが、値の扱い方は統一したい場合がある。

以下の場合はどちらのケースの場合もnullを設定するようにする。

const nullableString = (stringType: ZodType) =>
  stringType
    .nullish()
    .transform((val: string | null | undefined) => val ?? null);

export const contactInsertSchema = z.object({
  email: nullableString(z.string().email())
});
  • nullish:nullとundefinedどちらも許容する。

ライブラリを使用したバリデーション

バリデーションにライブラリを使用したい場合がある。以下はlibphonenumber-jsで日本で使用できる電話番号かをチェックしている。

const japanPhoneNumberType = z
  .string()
  .refine((val) => isValidPhoneNumber(val, "JP"), {
    message: "利用できない電話番号が入力されました。",
  });

export const contactInsertSchema = z.object({
  phoneNumber: japanPhoneNumberType
});

エラーメッセージの日本語化

zod-i18nで日本語化できる。仕組みとしては、i18nextに対してzod-i18nが用意したJSONファイルを読み込ませているのみである。プロジェクト内でzod-i18nのJSONファイルを参考にプロジェクト内にjsonファイルを用意して読み込ませれば良い。

import i18next from "i18next";
import translation from "zod-i18n-map/locales/ja/zod.json";

await i18next.init({
  lng: "ja",
  resources: {
    ja: { zod: translation },
  },
});
z.setErrorMap(zodI18nMap);

ZodErrorの内容がJSON文字列で格納されてしまう

zodが出力したバリデーションエラーをそのまま返すとエラー内容がJSON文字列で返されてしまい、扱いづらい。

以下は、配列で渡された連絡先情報(電話番号・メールアドレス)を登録しようとしたケースである。

1件目は電話番号が未入力のためエラー、2件目はメールアドレスが不正な値のためエラーとなっている。

{
  "statusCode": 400,
  "code": "FST_ERR_VALIDATION",
  "error": "Bad Request",
  "message": "[\n  {\n    \"code\": \"invalid_type\",\n    \"expected\": \"string\",\n    \"received\": \"undefined\",\n    \"path\": [\n      \"contacts\",\n      0,\n      \"phoneNumber\"\n    ],\n    \"message\": \"必須\"\n  },\n  {\n    \"validation\": \"email\",\n    \"code\": \"invalid_string\",\n    \"message\": \"メールアドレスの形式で入力してください。\",\n    \"path\": [\n      \"contacts\",\n      1,\n      \"email\"\n    ]\n  }\n]"
}

上記の問題を解決するためにfastify-errorを使用してカスタムエラーを作成する。

  • base.error.ts:エラーの基底クラス。
  • validation.error.ts:バリデーションエラーのクラス。ZodIssueを追加する。
  • costomizeErrorHandler:Fastifyのエラーハンドリングをカスタマイズする関数
base.error.ts
export abstract class BaseError {
  statusCode?: number;

  code: string;

  error: string;

  constructor(code: string, error: string, statusCode?: number) {
    this.statusCode = statusCode;
    this.code = code;
    this.error = error;
  }
}
validation.error.ts
import { ZodIssueBase } from "zod";

import { BaseError } from "./base.error";

export class ValidationError extends BaseError {
  issues: ZodIssueBase[];

  constructor(
    code: string,
    error: string,
    issues: ZodIssueBase[],
    statusCode?: number,
  ) {
    super(code, error, statusCode);
    this.issues = issues;
  }
}
  • Error Handling in Zodによると、Zodで発生したエラーの詳細はZodIssueに記載される。
  • ZodError.tsを見ると、ZodIssueの基底クラスがZodIssueBaseなので、ZodIssueBaseを定義する。
  • ZodIssueのうちpathがエラーの発生箇所、messageがエラーメッセージが格納されており、基底クラスの情報のみでエラーの特定ができるという判断で扱う情報を基底クラスのZodIssueBaseのみに絞っている。

setErrorHandlerを使用すると、エラーハンドリングをカスタマイズできるので、ZodErrorだった場合はカスタムエラーを返すようにする。

costomizeErrorHandler
const costomizeErrorHandler = (fastify: FastifyInstance) => {
  fastify.setErrorHandler(async (error: FastifyError, request, reply) => {
    request.log.error(error);
    if (error instanceof ZodError) {
      const zodError = error as ZodError;
      const issues = zodError.issues.map((issue: ZodIssue) => ({
        message: issue.message,
        path: issue.path,
      }));
      const validationError = new ValidationError(
        error.code,
        "Bad Request",
        issues,
        error.statusCode,
      );
      await reply.status(400).send(validationError);
      return;
    }
    await reply.send(error);
  });
};

カスタマイズ後のエラーは以下のようになる。

{
  "statusCode": 400,
  "code": "FST_ERR_VALIDATION",
  "error": "Bad Request",
  "issues": [
    {
      "message": "必須",
      "path": [
        "contacts",
        0,
        "phoneNumber"
      ]
    },
    {
      "message": "メールアドレスの形式で入力してください。",
      "path": [
        "contacts",
        1,
        "email"
      ]
    }
  ]
}

Discussion