Fastify+Zodで入力チェックする際の覚え書き
公式で紹介されているFastify Type Provider Zodを使用すると、FastifyでZodを使用できる。
サンプルのプロジェクトを作成したので、実際の動作はそちらを参照。
実装の際に気を付けたことや詰まった点について記載する。
バリデーションスキーマの渡し方
Fastifyでバリデーションを行う場合は、値の渡し方によって実装が異なる。
fastify.get('/the/url', { querystring: { body: validationSchema } }, handler)
fastify.post('/the/url', { schema: { body: validationSchema } }, handler)
また、型情報も渡せるのでバリデーションスキーマに併せて実装しておくと良い。
request: FastifyRequest<{ Querystring: ValidationSchema }>
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文字以上でない場合、必須エラーを発生させる。
- parseIntでnumber型に変換する
- 数値じゃない場合は、エラーを発生させる。
スキーマと型の宣言
z.object
バリデーションスキーマはオブジェクト型で渡すため、z.objectを使用する。
const userInsertSchema = z.object({
firstName: z.string().max(256).min(1),
lastName: z.string().max(256).min(1),
});
const userInsertSchema = {
firstName: z.string().max(256).min(1),
lastName: z.string().max(256).min(1),
};
z.infer
バリデーションスキーマのTypescriptの型情報をわたす場合はz.inferを使用すること。
type UserInsertSchema = z.infer<typeof userInsertSchema>;
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のエラーハンドリングをカスタマイズする関数
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;
}
}
- fastify-errorのcreateErrorを参考に実装。
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だった場合はカスタムエラーを返すようにする。
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