🦔

NestJSでZodを使用した開発を考えてみる

2023/12/17に公開

この記事は、NestJS Advent Calendar 2023の17日目の記事です。

対象読者

この記事の対象読者です。

  • NestJSで開発したことがある
  • Zodを使用したことがある

詳細な説明はしていませんので、NestJS, Zod知らない方は、まずはそれぞれの入門記事を読むことをお勧めします。

はじめに

NestJSでvalidationを行う場合、class-validatorを使用した方法が一般的かと思います。
その他に、Object Schemaを利用したvalidationを行うこともできます。
公式ドキュメントのPipes#Object schema validationでは、Zodを使用したvalidation方法が紹介されています。

今回は、NestJSでZodを使用した開発を行う場合、どういった方法になるだろうか考え、実際に試してみましたのでまとめます。

試したコードはこちらから確認できます。
https://github.com/ikkyu-3/using-zod-to-openapi-with-NestJS

前提

以下を前提に考えています。

  • フロントエンドとバックエンドは、それぞれ開発する担当者が分かれている
  • 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定義は、以下の方法が考えられました。

  1. Swagger EditorなどでAPIを定義し、その後Zodスキーマを生成する
  2. Zodスキーマを定義してから、APIファイルを生成する

どちらでやるかは、それぞれチームで判断することでよさそうです。
今回は、2を採用してみました。採用した理由としましては、

  • OpenAPI仕様の理解が浅いため、複雑なスキーマを定義することになった場合、生成されるZodスキーマが想定通りであるかどうか自信がない😇

です。

2を採用しましたので、Zodスキーマを使用してOpenAPIドキュメントを生成します。
幸いにも、zod-to-openapiというパッケージがありますので、こちらを使用することにしました。

1.1 スキーマ定義

では実際にAPIを定義していきます。今回は例として、こちらのPetstoreを元にAPIを定義していきます。

まずはZodスキーマを定義していきます。

packages/openapi/src/schemas.ts
...

// 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ドキュメントを生成するコードを実装します。

packages/openapi/src/generate.ts
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で紹介されています。こちらのコードを元に実装します。

apps/server/src/pipes/zod-validation.pipe.ts
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 ParameterQuery Parameterが考えられます。

2.2 API実装

次にcontrollerを作成していきます。

openapiパッケージでは以下のAPIを定義しました。

GET /pets
GET /pets/:id
POST /pets

ここではPOSTを例に実装を紹介します。

apps/server/src/modules/pets/pets.controller.ts
@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スキーマを使用するというより、型を使用する例が多いかと思います。

apps/web/app/page.tsx
...
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