技術本部 Digitization部の湯村です。
新規アプリケーション開発で採用したバリデーションロジックの管理方法を紹介します。
1. はじめに
2023年末に以下の技術スタックでデータ化アプリケーションの開発をしました。
- フロントエンド: TypeScript + Next.js
- バックエンド: TypeScript + Express
Next.js では App Router を採用しましたが、Server Components、Route Handler は利用せず、ブラウザから Express の API を呼び出す構成にしました。
SPA + API で開発する際の課題
この構成で開発をする際の課題の1つにフロントエンドとバックエンドでのコードの重複があります。
特にバリデーションのロジックの管理方法は頭を悩ませた方も多いはずです。
バリデーションに対するアプローチ
バリデーションのロジックを管理する方法は以下のいずれかになるはずです。
- フロントエンド、バックエンドのいずれかにロジックを寄せる
- フロントエンド、バックエンドの両方でバリデーションをかける
それぞれのバリデーションの重要性
システムを不正な入力から守り、利用者に不正な入力であることを素早くフィードバックするためにもフロントエンドとバックエンドの両方でバリデーションを実行するのが理想的です。
ただし両方でバリデーションを実行すると実装の手間が増え、ロジックの不整合も発生しやすくなります。
2. 目指したこと
今回開発したアプリケーションでは、このような課題を解決しながらフロントエンドとバックエンドの両方でバリデーションを実行する方法を模索しました。
3. 採用したアプローチ
いくつかのアプローチを検討しましたが、今回は OpenAPI の定義ファイルを利用してバリデーションのコードを自動生成しました。
フロントエンドとバックエンドの詳細な実装フローを紹介します。
フロントエンド
1. OpenAPI の定義から Zod のスキーマを生成する
openapi-zod-client というライブラリを利用しました。
Zodios という型安全な API クライアントを生成するライブラリですが、Zod のスキーマも同時に生成します。
OpenAPI のスキーマオブジェクトで定義された minLength や required も反映できます。
OpenAPI の定義
paths: /products: post: operationId: createProduct requestBody: content: application/json: schema: $ref: "#/components/schemas/CreateProductPayload" responses: "200": description: OK components: schemas: CreateProductPayload: type: object properties: name: type: string minLength: 1 description: type: string required: - name
生成される Zod のスキーマ
const CreateProductPayload = z .object({ name: z.string().min(1), description: z.string().optional() }) .passthrough();
2. Zod のオブジェクトを使ってフォームのバリデーションを実行する
react-hook-form と resolvers というライブラリを利用しました。
次のように、生成した Zod のスキーマを渡すことでバリデーションを実行できます。
import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { schemas } from "./generated/schema"; // 生成された Zod のスキーマ const CreateProductPayload = schemas.CreateProductPayload.strict(); function Form() { const { handleSubmit, register, formState: { errors, isValid }, } = useForm<z.infer<typeof CreateProductPayload>>({ resolver: zodResolver(CreateProductPayload), }); return <form />; }
react-hook-form の formState や zod-i18n を合わせて活用すると、エラーメッセージの表示や有効な値が入力されるまで送信ボタンを disabled にするなどの対応も簡単に行えます。
OpenAPI の定義からこのようなフォームの実装を効率的に行えます。
バックエンド
1.OpenAPI の定義からバリデーションロジックを生成する
express-openapi-validator を利用しました。
unopinionated ライブラリで Express にミドルウェアを追加するだけでバリデーションを実行できます。
OpenAPI で定義されていないクエリパラメーターの扱い方など、バリデーションのオプションが豊富である点も魅力です。
2. OpenAPI の定義からリクエストの型を生成する
express-openapi-validator はバリデーションを実行するミドルウェアです。バリデーションが実行されたリクエストのパラメーターに型がつくわけではありません。
型の再定義を避けるために swagger-typescript-api を採用して、リクエストハンドラーに型をつけました。
バックエンドでの実装は過去に弊社の秋山が書いた記事を参考にしました。
buildersbox.corp-sansan.com
このように、ライブラリを活用することで OpenAPI の定義をベースにフロントエンドとバックエンドで一貫したバリデーションを効率的に実装できました。
4. OpenAPI の定義のさらなる活用
さらなる効率化のために、バリデーション以外にも OpenAPI の定義を活用しました。
API クライアントの自動生成
swagger-typescript-api を利用して API クライアントを自動生成しています。
openapi-zod-client で生成した Zodios をそのまま使っても良かったのですが、自動生成のオプションの豊富さ、生成される型の使いやすさを評価して採用しました。
API のモックデータを半自動生成
zod-fixture を利用して Zod のスキーマからモックデータを生成しました。
import { Fixture } from "zod-fixture"; import { schemas } from "./generated/schema"; // 生成された Zod のスキーマ export function buildProduct() { return new Fixture().fromSchema(schemas.Product); }
このモックデータを MSW のレスポンスに使用して、モックを利用したフロントエンドの動作確認を簡単に実施できるようにしました。
さらにこの MSW のハンドラーは Storybook の Play function を使ったテストでも利用しています。これによりインタラクションテストの実装コストも削減しました。