unjs/h3 のバリデーションを Joi で書く

[unjs/nitro](https://nitro.unjs.io/) や [Nuxt3](https://nuxt.com/) が依存する [unjs/h3](https://github.com/unjs/h3)（以後 h3） について [zod](https://zod.dev/) を使用したバリデーションの書き方は公式のドキュメントや色々なサイトで紹介されていますが、他のバリデーションライブラリ（今回は [Joi](https://joi.dev/) ）での書き方が見当たらなかったので作成しました。

特にこだわりが無ければ公式のドキュメントでも紹介されている zod と組み合わせて使うのが良いと思います。今回は
- Joi のバリデーションメソッドに渡す「秘伝のたれ」化したオプション情報が存在する
- スキーマで定義するメッセージについてラベル部分をメソッドチェーンの中で都度書きたくない（zod は2024年現在も標準ではサポートしていない？）

といった事情があり、h3 × Joi の組み合わせについてコードを書きながら考えたという内容になります。

## おさらい① POST/GET のデータの受け取り方
h3 ではサーバーサイドの組み立てを行うにあたり、同ライブラリが提供する H3Event という型を前提としたイベントハンドラを書きます。

POSTリクエストによって送られたリクエストボディを受け取る方法
```typescript:server/api/demo.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
});
```

GETリクエストによって送られたクエリパラメータを受け取る方法
```typescript:server/api/demo.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event);
});
```

## 検証用のバリデーションとデータについて
この記事内で扱うサンプルコードについて実施するバリデーションとデータを以下のように定めます。

- application/json なデータを POST する
- POST 内容として以下の3種類のデータを送信
  - `user.name`: 名前、文字列、必須、20文字以内
  - `user.age`: 年齢、数値、必須、18～100の範囲
  - `interests`: 興味・関心、文字列の配列、重複はNG
- 具体的には POST するデータは以下の2種類

```json:正常時のリクエスト例
{
  "user": {
    "name": "ぼこちょ",
    "age": 18
  },
  "interests": [
    "Vue",
    "PHP",
    "TypeScript"
  ]
}
```

```json:エラー時のリクエスト例
{
  "user": {
    "name": "",
    "age": 17
  },
  "interests": [
    "Vue",
    "PHP",
    "TypeScript",
    "PHP"
  ]
}
```

## おさらい② zod を使用したバリデーション
h3 では getQuery/readBody をラップした getValidatedQuery/readValidatedBody というバリデーション用のメソッドを提供しています。

```typescript:server/api/demo.post.ts
import { z } from "zod";

export default defineEventHandler(async (event) => {
  const schema = z.object({
    user: z.object({
      name: z
        .string()
        .min(1, { message: "名前を入力してください" })
        .max(20, { message: "名前は20文字以内で入力してください" }),
      age: z
        .number()
        .min(18, { message: "年齢は18以上で入力してください" })
        .max(100, { message: "年齢は100以下で入力してください" }),
    }),
    interests: z
      .array(z.string())
      .nonempty({ message: "興味・関心は少なくとも1つ入力してください" })
      .refine((values) => new Set(values).size === values.length, {
        message: "興味・関心の値が重複しています",
      }),
  });
  const { success, error } = await readValidatedBody(event, (body) => schema.safeParse(body));
  if (!success) {
    return error.issues;
  }

  return {
    status: true,
  };
});
```

- zod は parse/safeParse というメソッドを使用して値のバリデーションを実施する
  - parse は入力値が不正だった時にエラーをthrowする
  - safeParse は入力値が不正だった時にエラーをthrowせずにエラー情報を返す
- 公式ドキュメントのサンプルコードでは `error.issues` についてreturn ではなく throw していますが、こちらは今回作成中のアプリケーション内においてエラーハンドリングがやや複雑なことに関連して正常時のリクエストと同様にreturnするようにしてます。。

```json:エラーリクエストを送った時にバリデーションエラーとして受け取った error.issues の中身
[
  {
    "code": "too_small",
    "minimum": 1,
    "type": "string",
    "inclusive": true,
    "exact": false,
    "message": "名前を入力してください",
    "path": [
      "user",
      "name"
    ]
  },
  {
    "code": "too_small",
    "minimum": 18,
    "type": "number",
    "inclusive": true,
    "exact": false,
    "message": "年齢は18以上で入力してください",
    "path": [
      "user",
      "age"
    ]
  },
  {
    "code": "custom",
    "message": "興味・関心の値が重複しています",
    "path": [
      "interests"
    ]
  }
]
```

---
なお、冒頭に書いたように getValidatedQuery/readValidatedBody は getQuery/readBody のラッパーなので以下のように使わずに書くこともできます。

```diff_typescript:server/api/demo.post.ts
import { z } from "zod";

export default defineEventHandler(async (event) => {
  const schema = z.object({
    // 同じなので省略
  });
+  const body = await readBody(event);
+  const { success, error } = schema.safeParse(body);
-  const { success, error } = await readValidatedBody(event, (body) => schema.safeParse(body));
  if (!success) {
    return error.issues;
  }

  return {
    status: true,
  };
});
```

<details>
<summary>このへん深堀りしたときのメモ</summary>

getValidatedQuery/readValidatedBody の第1引数(event)は H3Event を渡せばいいだけなので getQuery/readBody を利用する場合と変わりません。
第2引数(validate)として渡すべき値についてエディタの補間より`ValidateFunction<...>`という型を満たしたものである必要がわかりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/bfce84bd-4083-3185-ffc4-f72d476736fc.png)

- 先の zod のサンプルコードや、型の名前から関数を渡せば良さそうなことは分かる
- 「`You can use a simple function to validate the query object or a library like zod to define a schema.`」 => 「単純な関数を使用してクエリ オブジェクトを検証したり、zod などのライブラリを使用してスキーマを定義したりできます。」とのこと（Google翻訳）

---
ValidateFunction は TypeScript の型情報なので h3 が提供するアプリケーションの javascript コードとは異なる場所(`h3/dist/index.d.ts`)にありました。
※今回はパッケージマネージャとして [pnpm](https://pnpm.io/ja/) を使用していたので npm を使用した場合に比べて少し深い場所にありました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/8f2ae6a8-443c-831f-85b1-57f4a3d682a8.png)

```typescript:node_modules/h3/dist/index.d.ts より
type ValidateResult<T> = T | true | false | void;
type ValidateFunction<T> = (data: unknown) => ValidateResult<T> | Promise<ValidateResult<T>>;
```

- ValidateFunction 型について unknown 型の data を引数として受け取り、`ValidateResult<T>` またはその Promise のいずれかを返す関数の必要がある
- ValidateResult<T> 型は ValidateFunction 型の1行上に記載があり、`ValidateFunction 型に渡された型引数(T) | true | false | void` のいずれかを返す必要がある
といったことが分かりました。

要するに zod を使用したバリデーションにおいて getValidatedQuery/readValidatedBody を使用したコードで返る値は `schama.safeParse(body)` の実行結果ということになります。

```typescript:server/api/demo.post.ts (抜粋)
const { success, error } = await readValidatedBody(event, (body) => {
  return schema.safeParse(body);
});
```

---
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/a7f02d6c-2636-5b3f-2aac-8b9ae2815945.png)

- `const { success, error }` の error にカーソルを当てたところ zod の ZodError という型情報が得られていることからもこの辺は合ってそうです

これらの情報により getValidatedQuery/readValidatedBody を利用したバリデーションについて zod でのみ使用可能なものということではなく、他のバリデーションライブラリでも問題なく利用可能であることがわかりました。

---
もう本題と全く関係ないですが、せっかくなので getValidatedQuery/readValidatedBody のコード本体を調べました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/b86abcd0-2b59-a834-08d1-5cc329b1c9d1.png)

- getQuery を内部で使用している
- validateData というメソッドに getQuery で取得したクエリパラメータと validate(ValidateFunction) を渡している

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/e763c5e8-a583-badb-163a-0dcccbf29316.png)

- readBody を内部で使用している
- こちらも getValidatedQuery と同じ要領で validateData メソッドを呼び出している

---
続けて getValidatedQuery/readValidatedBody の中で呼び出している validateData メソッドを確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/8ba2b407-100c-c351-34e7-121c6ba52396.png)

- getValidatedQuery/readValidatedBody メソッドの中では特にいじらずにそのままの形で渡された fn は サンプルコードを例とした場合 `(body) => schema.safeParse(body)` のこと
- 前述の通り ValidateFunction の戻り値(res)は `ValidateResult<T> | Promise<ValidateResult<T>>` 型であり、ValidateResult 型は `渡された型引数(T) | true | false | void` のいずれか
- res === false のときは createValidationError というエラーを throw する
- res === true のときは getQuery/readBody によって取得したクエリパラメータ/リクエストボディを返す
- res が null や undefined のときは getQuery/readBody によって取得したクエリパラメータ/リクエストボディを返す。値が入っているときはそれをそのまま返す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/c1c6ab00-2cdc-913a-2d91-583b2614e3ef.png)
↑ChatGPTに説明したら関係図を作ってくれた。
</details>

## (本題) Joi を使用したバリデーション
zod 以外でも getValidatedQuery/readValidatedBody を使用したバリデーションが書けることが深堀りによって分かったためまずは書いてみます。

```typescript:server/api/demo.post.ts
import Joi from "joi";
import { options } from "~/utils/joi";

export default defineEventHandler(async (event) => {
  const schema = Joi.object({
    user: Joi.object({
      name: Joi.string().required().max(20).label("名前"),
      age: Joi.number().min(18).max(100).label("年齢"),
    }),
    interests: Joi.array().items(Joi.string()).unique().label("興味・関心"),
  });
  const { value, error } = await readValidatedBody(event, (body) => schema.validate(body, options));
  if (error) {
    return error.details;
  }

  // ↓バリデーションエラーチェックが正常だった時の後続処理
  console.log(value);
  // {
  //   user: { name: 'ぼこちょ', age: 18 },
  //   interests: [ 'Vue', 'PHP', 'TypeScript' ]
  // }

  return {
    status: true,
  };
});
```

- Joi では `schema.validate(data)` でバリデーションを実行します
- validate メソッドに渡すオプションは以下のようなものとしています

```typescript:server/utils/joi.ts
import type { ValidationOptions } from "joi";

/**
 * Joiのバリデーションオプション
 */
export const options: ValidationOptions = {
  // 全ての項目の検証を行う
  abortEarly: false,
  // エラーメッセージ内のラベルをラップするダブルクォートを無効化
  // 「"名前" を入力してください」⇒「名前 を入力してください」
  errors: {
    wrap: {
      label: "",
    },
  },
  // 実際には日本語メッセージ用に messages も色々書いている（記事の冒頭に書いた「秘伝のたれ部分」。長いので省略）
};
```

バリデーションエラーがある場合の error は Joi.ValidationError という型のオブジェクトです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/04b3458d-e468-1e14-724a-178c4d3441ed.png)

その中で具体的なエラーメッセージ情報は details 内に含まれており、Joi.ValidationErrorItem という型の配列になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/6f495d71-1855-806c-d9ce-285838e5a642.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/156330bd-8e35-3f1a-b778-8b1762e793d6.png)

```json:エラーリクエストを送った時にバリデーションエラーとして受け取った error.details の中身
[
  {
    "message": "名前 を入力してください",
    "path": [
      "user",
      "name"
    ],
    "type": "string.empty",
    "context": {
      "label": "名前",
      "value": "",
      "key": "name"
    }
  },
  {
    "message": "年齢 は 18 以上で入力してください",
    "path": [
      "user",
      "age"
    ],
    "type": "number.min",
    "context": {
      "limit": 18,
      "value": 17,
      "label": "年齢",
      "key": "age"
    }
  },
  {
    "message": "興味・関心 の値が重複しています",
    "path": [
      "interests",
      3
    ],
    "type": "array.unique",
    "context": {
      "pos": 3,
      "value": "PHP",
      "dupePos": 1,
      "dupeValue": "PHP",
      "label": "興味・関心",
      "key": 3
    }
  }
]
```

## defineEventHandler内のコードが最小限となるオレオレバリデーションメソッドを作る
Joi を使用しつつ getValidatedQuery/readValidatedBody を利用したバリデーションチェックが行えることは確認できましたが、こちら最終的には以下のような要件を盛り込んだオレオレバリデーションメソッドを作ることにしました。

- GET/POST の両リクエストに対応する
- エラー時のレスポンスステータスは422にする
- バリデーションエラー情報は `Record<string, string>` 型に整形
  - key側はドットチェイン形式（ `user.name`、`user.age`、`interests.1`のような感じ）とする

出来上がったものが以下

```diff_typescript:types/index.d.ts
+export type JoiValidationError = Record<string, string>;
```
- バリデーションエラー用の型を作成

```typescript:server/utils/joi.ts
import type { ObjectSchema, ValidationOptions, ValidationErrorItem } from "joi";
import type { H3Event } from "h3";
import type { JoiValidationError } from "~~/types";

export async function validateWithJoiSchema(
  event: H3Event,
  schema: ObjectSchema,
): Promise<{ value: any; error?: JoiValidationError }> {
  // GET/POST の両方に対応するので getValidatedQuery/readValidatedBody は使わない方がシンプルになる
  const data = (event.method === "GET" && getQuery(event)) || (await readBody(event));

  const { value, error } = schema.validate(data, options);

  if (error) {
    setResponseStatus(event, 422);
    return {
      value,
      error: convertValidationErrors(error.details),
    };
  }

  return {
    value,
  };
}

/**
 * ValidationErrorItem[] ⇒ JoiValidationError 変換
 */
export const convertValidationErrors = (details: ValidationErrorItem[]): JoiValidationError => {
  return details.reduce((obj, detail) => {
    const path = detail.path.join(".");
    const message = detail.message;
    obj[path] = message;
    return obj;
  }, {});
};

/**
 * Joiのバリデーションオプション
 */
const options: ValidationOptions = {
  // 全ての項目の検証を行う
  abortEarly: false,
  // エラーメッセージ内のラベルをラップするダブルクォートを無効化
  // 「"名前" を入力してください」⇒「名前 を入力してください」
  errors: {
    wrap: {
      label: "",
    },
  },
};
```
- validateWithJoiSchema メソッドで H3Event と Joi のバリデーションスキーマ情報を受け取りバリデーションチェックを実施
- convertValidationErrors メソッドでバリデーションエラーを自作の JoiValidationError 型情報に合致する形へ整形


---
利用する側のコードは以下のような感じ
```typescript:server/api/demo.post.ts
import Joi from "joi";

export default defineEventHandler(async (event) => {
  const schema = Joi.object({
    user: Joi.object({
      name: Joi.string().required().max(20).label("名前"),
      age: Joi.number().min(18).max(100).label("年齢"),
    }),
    interests: Joi.array().items(Joi.string()).unique().label("興味・関心"),
  });

  const { value: validated, error } = await validateWithJoiSchema(event, schema);
  if (error) {
    return error;
  }

  // ↓バリデーションエラーチェックが正常だった時の後続処理
  console.log(validated);
  // {
  //   user: { name: 'ぼこちょ', age: 18 },
  //   interests: [ 'Vue', 'PHP', 'TypeScript' ]
  // }
});
```

```json:エラーリクエストを送った時のバリデーションエラー
{
  "user.name": "名前 を入力してください",
  "user.age": "年齢 は 18 以上で入力してください",
  "interests.3": "興味・関心 の値が重複しています"
}
```

## 続・defineEventHandler内のコードが最小限となるオレオレバリデーションメソッドを作る
先ほどの作成したオレオレバリデーションメソッド(`validateWithJoiSchema`)について利用する側で都度エラーがあったときのハンドリングを書かなくて良くなるような改善(？)をしてみました。
具体的には以下のような修正を行います。

- バリデーションエラーがあったときに `HTTPステータスコード=422` のエラーをthrowする
- エラーハンドラー側でバリデーションエラー情報が扱えるようunknownな値を設定可能なdataにセット
- エラーハンドラー側では `HTTPステータスコード=422` のエラーを受けたときのみセットされたdataを返すハンドリングを実施
- 2ファイル(オレオレバリデーションメソッドを置いてる `server/utils/joi.ts` とエラーハンドラー `server/error.ts`)間で「HTTPステータスコードが422のとき」という取り決めを行うため422というステータスコードは定数として扱う
- `validateWithJoiSchema` メソッドの戻り値として返す値はバリデーションエラーが無かった時の検証済みのデータのみとなるため、その辺も併せて修正

```typescript:server/utils/http-status.ts
/**
 * 422 エラーステータスコード
 */
export const HTTP_UNPROCESSABLE_ENTITY = 422;
```

```diff_typescript:server/utils/joi.ts (エラーをthrowする版)
import type { ObjectSchema, ValidationOptions, ValidationErrorItem } from "joi";
import type { H3Event } from "h3";
import type { JoiValidationError } from "~~/types";
+import { HTTP_UNPROCESSABLE_ENTITY } from "./http-status";

export async function validateWithJoiSchema(
  event: H3Event,
  schema: ObjectSchema,
-): Promise<{ value: any; error?: JoiValidationError }> {
+): Promise<any> {
  // GET/POST の両方に対応するので getValidatedQuery/readValidatedBody は使わない方がシンプルになる
  const data = (event.method === "GET" && getQuery(event)) || (await readBody(event));

  const { value, error } = schema.validate(data, options);

  if (error) {
+    throw createError({
+      statusCode: HTTP_UNPROCESSABLE_ENTITY,
+      data: convertValidationErrorItem(error.details),
+    });
-    setResponseStatus(event, 422);
-    return {
-      value,
-      error: convertValidationErrors(error.details),
-    };
  }

-  return {
-    value,
-  };
+  return value;
}
```

```diff_typescript:server/error.ts
import type { H3Error, H3Event } from "h3";
+import { HTTP_UNPROCESSABLE_ENTITY } from "./utils/http-status";

export default defineNitroErrorHandler((error: H3Error, event: H3Event) => {
  setResponseHeader(event, "Content-Type", "application/json");

+  if (error.statusCode === HTTP_UNPROCESSABLE_ENTITY) {
+    return send(event, JSON.stringify(error.data));
+  }

  // 422エラー以外のハンドリング(省略)
});
```

```typescript:nitro.config.ts
export default defineNitroConfig({
  srcDir: "server",
  errorHandler: "~/error",
});
```

この修正によって各API側のコードは以下のように変わりました。
```diff_typescript:server/api/demo.post.ts
import Joi from "joi";

export default defineEventHandler(async (event) => {
  const schema = Joi.object({
    user: Joi.object({
      name: Joi.string().required().max(20).label("名前"),
      age: Joi.number().min(18).max(100).label("年齢"),
    }),
    interests: Joi.array().items(Joi.string()).unique().label("興味・関心"),
  });

-  const { value: validated, error } = await validateWithJoiSchema(event, schema);
-  if (error) {
-    return error;
-  }
+  const validated = await validateWithJoiSchema(event, schema);

  // ↓バリデーションエラーチェックが正常だった時の後続処理
  console.log(validated);
  // {
  //   user: { name: 'ぼこちょ', age: 18 },
  //   interests: [ 'Vue', 'PHP', 'TypeScript' ]
  // }
});
```
- 1行のバリデーションメソッドの呼び出し後、後続処理においてエラーが無いことを保証できるコードになりました(多分)
- エラーハンドラー内に422エラーのときだけ異なるレスポンスを返すという処理が入るため、その辺TypeScriptでしっかり補間の効くような修正を行えると尚良いかも
- もしバリデーションエラーをフロントへ返す前に固有のハンドリングを行いたい場合はAPIの中で`validateWithJoiSchema`をtry/catchでラップすればOK。catch句のerrorからバリデーションエラーを取得することも以下のようなコードで一応可能

```typescript
try {
  const validated = await validateWithJoiSchema(event, schema);
} catch (error) {
  const h3error = error as H3Error<JoiValidationError>
  console.log(error.data);
}
```

---
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/30b88b96-3ce7-4dcb-af04-85b91e87216d.png)
- createError で statusCode に 422 をセットしたエラーをthrowしているため、setResponseStatus によるレスポンスステータスの指定をせずにHTTPレスポンスステータスが422になっていることが確認できました（POST API のためエディタにインストールした [ThunderClient](https://www.thunderclient.com/) という拡張機能より確認）

---
何となく考慮不足感がある。。間違いがありましたら指摘いただけると嬉しいです。:open_mouth:

## 参考サイト
- [Validate Data - h3](https://h3.unjs.io/examples/validate-data)
  - h3 公式のバリデーションに関するドキュメント
  - zod を利用したバリデーションに関する説明などが載っています
- [TypeScriptのゾッとする話 ~ Zodの紹介 ~](https://zenn.dev/uttk/articles/bd264fa884e026)
  - zod の parse と safeParse って何が違うんだっけ？ってのを忘れたときに読んだページ