NestJSでZodを使用した開発を考えてみる
この記事は、NestJS Advent Calendar 2023の17日目の記事です。
対象読者
この記事の対象読者です。
- NestJSで開発したことがある
- Zodを使用したことがある
詳細な説明はしていませんので、NestJS, Zod知らない方は、まずはそれぞれの入門記事を読むことをお勧めします。
はじめに
NestJSでvalidationを行う場合、class-validatorを使用した方法が一般的かと思います。
その他に、Object Schemaを利用したvalidationを行うこともできます。
公式ドキュメントのPipes#Object schema validationでは、Zodを使用したvalidation方法が紹介されています。
今回は、NestJSでZodを使用した開発を行う場合、どういった方法になるだろうか考え、実際に試してみましたのでまとめます。
試したコードはこちらから確認できます。
前提
以下を前提に考えています。
- フロントエンドとバックエンドは、それぞれ開発する担当者が分かれている
- APIを定義してからそれぞれ開発を行う(APIファースト)
- Monorepoで開発
1. プロジェクト作成
まずは、プロジェクトを作成します。
Monorepoでの開発を想定していますので、Turborepoを使用してプロジェクトを作成します。
npx create-turbo@latest
その後、packages配下にAPIを定義するopenapi
、apps配下にserver
(NestJS)を作成します。
今回使用しないものは削除し、作成したプロジェクトは以下のようになりました。
.
├── README.md
├── apps
│ ├── server
│ └── web
├── package-lock.json
├── package.json
├── packages
│ ├── eslint-config
│ ├── openapi
│ └── typescript-config
├── tsconfig.json
└── turbo.json
1. APIを定義
openapi
にAPIを定義していきます。
API定義は、以下の方法が考えられました。
- Swagger EditorなどでAPIを定義し、その後Zodスキーマを生成する
- Zodスキーマを定義してから、APIファイルを生成する
どちらでやるかは、それぞれチームで判断することでよさそうです。
今回は、2を採用してみました。採用した理由としましては、
- OpenAPI仕様の理解が浅いため、複雑なスキーマを定義することになった場合、生成されるZodスキーマが想定通りであるかどうか自信がない😇
です。
2を採用しましたので、Zodスキーマを使用してOpenAPIドキュメントを生成します。
幸いにも、zod-to-openapiというパッケージがありますので、こちらを使用することにしました。
1.1 スキーマ定義
では実際にAPIを定義していきます。今回は例として、こちらのPetstoreを元にAPIを定義していきます。
まずはZodスキーマを定義していきます。
...
// Zodスキーマと型をexportする
export const PetSchema = z.object({
id: z.number().int(),
name: z.string(),
tag: z.string().optional(),
});
export type Pet = z.infer<typeof PetSchema>;
...
1.2 OpenAPIドキュメントを生成
Zodスキーマを利用して、OpenAPIドキュメントを生成するコードを実装します。
const registry = new OpenAPIRegistry();
// スキーマ定義
const petSchema = registry.register("Pet", PetSchema);
...
// Pathを定義
registry.registerPath({
path: "/pets",
method: "get",
...
});
...
function getOpenApiDocumentation() {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.0",
...
});
}
function writeDocumentation() {
const docs = getOpenApiDocumentation();
const fileContent = yaml.stringify(docs);
...
}
writeDocumentation();
これでOpenAPIドキュメントを生成できます。
生成したOpenAPIドキュメント
openapi: 3.0.0
info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT
servers:
- url: http://example.com/v1
components:
schemas:
Pet:
type: object
properties:
id:
type: integer
name:
type: string
tag:
type: string
required:
- id
- name
NewPet:
type: object
properties:
name:
type: string
tag:
type: string
required:
- name
Pets:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
tag:
type: string
required:
- id
- name
maxItems: 100
Error:
type: object
properties:
code:
type: integer
message:
type: string
required:
- code
- message
parameters: {}
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- schema:
type: integer
maximum: 100
required: false
description: How many items to return at one time (max 100)
name: limit
in: query
responses:
"200":
description: A paged array of pets
content:
application/json:
schema:
$ref: "#/components/schemas/Pets"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
post:
summary: Create a pet
operationId: createPets
tags:
- pets
requestBody:
description: pet data
content:
application/json:
schema:
$ref: "#/components/schemas/NewPet"
responses:
"201":
description: pet response
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/pets/{petId}:
get:
summary: Info for a specific pet
operationId: showPetById
tags:
- pets
parameters:
- schema:
type: string
required: true
description: The id of the pet to retrieve
name: petId
in: path
responses:
"200":
description: Expected response to a valid request
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
1.3 bundle
NestJSで定義したZodスキーマを使用するには、CommonJSで提供する必要がありますのでbundleします。
bundle準備として以下を行いました。
- entryファイル作成し、Zodスキーマと型をexportする
- bundleツールの準備
- tsupを使用してみました
- package.jsonにexportsフィールドを定義する
準備できたらbundleします。
これで、定義したZodスキーマをNestJSで使用できる準備が整いました🎉
2. NestJSでAPIを実装する
1ではAPIを定義しました。次は、NestJSでZodスキーマを利用してAPIを実装します。
2.1 ZodValidationPipe作成
NestJSでZodを使用する方法は、公式ドキュメントPipes | NestJS - A progressive Node.js frameworkで紹介されています。こちらのコードを元に実装します。
import {
PipeTransform,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ZodType } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodType) {}
transform(value: unknown, metadata: ArgumentMetadata) {
try {
this.schema.parse(value);
} catch (error) {
throw new BadRequestException('Validation failed');
}
return value;
}
}
constructorで渡すZodスキーマの型をZodType
にしているのは、公式で指定されているZodObject
以外も利用できるようにするためです。
想定されるケースとして、Path Parameter
やQuery Parameter
が考えられます。
2.2 API実装
次にcontrollerを作成していきます。
openapiパッケージでは以下のAPIを定義しました。
GET /pets
GET /pets/:id
POST /pets
ここではPOSTを例に実装を紹介します。
@Controller('pets')
export class PetsController {
...
@Post()
createPet(@Body(new ZodValidationPipe(NewPetSchema)) body: NewPet) {
// 処理
...
return response
}
...
}
やっていることはシンプルです。これだけで、Zodスキーマを利用したvalidationが実装できます。
同様に@Param
デコレータ, @Query
デコレータにもこの方法でvalidationできます。
実装例はこちらから確認できます。
controller作成後、moduleに追加してAPI実装は終わりです。
2.3 テスト
実際にvalidationが機能しているのかテストしてみます。
サーバ起動
npm run dev
成功するリクエスト
curl -X POST -H "Content-Type: application/json" -d '{"name":"タロウ"}' http://localhost:3000/pets
> {"id":1,"name":"タロウ"}
失敗するリクエスト
curl -X POST -H "Content-Type: application/json" -d '{"hoge":"foo"}' http://localhost:3000/pets
> {"message":"Validation failed","error":"Bad Request","statusCode":400}
どちらも想定通りの挙動となりました🎉
3. フロントエンドでの利用
フロントエンドの利用ではZodスキーマを使用するというより、型を使用する例が多いかと思います。
...
import { Error, Pets } from "@repo/openapi";
export default function Page(): JSX.Element {
const { data, error, isLoading } = useSWR<Pets, Error>("/pets", fetcher);
...
}
まとめ
NestJSでZodを使用した開発を考えてみました。
フロントエンドもバックエンドもZodスキーマや型を共有できるので、効率的な開発ができる可能性を感じます。
また、実装していて、Monorepoでなくても、GitHub PackagesでAPI定義を提供する方法もありかなと思いました。
Discussion