NestJS Prisma Zod OpenAPI で型を堅牢にする

NestJS Prisma Zod OpenAPI で型を堅牢にする

2023-03-1334 min read

目次

  1. nestjs-のセットアップ
  2. prisma-のセットアップ
  3. zod-導入
  4. エンドポイントの作成
  5. openapi-の組み込み
  6. 参考にしたサイト

NestJS のセットアップ

まず、NestJS をセットアップするために、以下のコマンドを実行します。

npm install -g @nestjs/cli
nest new myapp

その後、パッケージマネージャを選択するように求められるので、適当なものを選択します。

Prisma のセットアップ

次に、Prisma をセットアップするために、以下のコマンドを実行します

cd myapp
npm install prisma --save-dev

Prisma のスキーマを初期化します。

npx prisma init

Schema の定義

以下のスキーマを定義します。

schema/prisma.schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean? @default(false)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}

.env に DB の接続情報を定義します。今回は sqlite を利用します。

.env

DATABASE_URL="file:./dev.db"

以下のコマンドを使用して、データベースをプッシュします。

npx prisma db push

ここまでできたら確認のために sqlite に入ります。

sqlite3 prisma/dev.db

以下のコマンドを使用して、テーブルの存在を確認します。

sqlite> .schema User
CREATE TABLE IF NOT EXISTS "User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "email" TEXT NOT NULL,
    "name" TEXT
);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

テストデータを投入します。

sqlite> insert into user (email, name) values ('test@example.com', 'test user');
sqlite> select * from User;
1|test@example.com|test user
sqlite>

PrismaService の作成

src/prisma.service.ts

import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on("beforeExit", async () => {
      await app.close();
    });
  }
}

Zod 導入

まずは tsconfig を調整します。 これが正しい設定でないと zod が機能しませんでした。

tsconfig.json

{
  "compilerOptions": {
    // 略
    "strict": true, // trueにする
    // "strictNullChecks": false, //これがfalseだと正しく動作しない
  }
}

そして zod と nestjs-zod をインストールします。

npm install zod nestjs-zod

エンドポイントの作成

http://localhost:3000/userの CRUD が行えるエンドポイントを作成していきます。

CRUD リソース作成

次のコマンドで基本的なリソース(GET,POST,PATCH,DELETE のエンドポイント)を作成します。

npx nest generate resource user

質問では RESTfull API を選択します。

これで controller service dto クラスが生成されます。

コード編集

コードを次のように変更します。

src/user/entity/user.entity.ts

ここでは Input/Update の際に利用する Dto や型を定義します。

import type { Prisma, User } from "@prisma/client";
import { createZodDto } from "nestjs-zod";
import { z } from "zod";

const user = z.object({
  id: z.number().int(),
  email: z.string().email("形式が不正です"),
  name: z.string().max(255, "255字未満で入力してください"),
});

// id は autoincrement で生成されるので除外する
export const UserCreateInputSchema: z.ZodType<Prisma.UserCreateInput> =
  user.omit({ id: true });

export const UserUpdateInputSchema: z.ZodType<Prisma.UserCreateInput> = user;

export const UserResponseSchema: z.ZodType<User> = user;

export type UserCreateInput = z.infer<typeof UserCreateInputSchema>;

export type UserUpdateInput = z.infer<typeof UserCreateInputSchema>;

export type UserResponse = z.infer<typeof UserResponseSchema>;

export class UserCreateInputDto extends createZodDto(UserCreateInputSchema) {}

export class UserUpdateInputDto extends createZodDto(UserUpdateInputSchema) {}

src/user/user.controller.ts

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UsePipes,
} from "@nestjs/common";
import { UserService } from "./user.service";
import { UserCreateInputDto, UserUpdateInputDto } from "./entities/user.entity";
import { ZodValidationPipe, zodToOpenAPI } from "nestjs-zod";

@Controller("user")
@ApiTags("user")
@UsePipes(ZodValidationPipe)
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async create(@Body() dto: UserCreateInputDto) {
    return this.userService.create(dto);
  }

  @Get()
  async findAll() {
    return this.userService.findAll();
  }

  @Get(":id")
  async findOne(@Param("id") id: string) {
    return this.userService.findOne(+id);
  }

  @Patch(":id")
  async update(@Param("id") id: string, @Body() dto: UserUpdateInputDto) {
    return this.userService.update(+id, dto);
  }

  @Delete(":id")
  async remove(@Param("id") id: string) {
    return this.userService.remove(+id);
  }
}

src/user/user.service.ts

import { Injectable } from "@nestjs/common";
import {
  UserCreateInput,
  UserResponse,
  UserUpdateInput,
} from "./entities/user.entity";
import { PrismaService } from "src/prisma.service";

@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  async create(dto: UserCreateInput): Promise<UserResponse> {
    return await this.prisma.user.create({
      data: dto,
    });
  }

  async findAll(): Promise<UserResponse[]> {
    return await this.prisma.user.findMany();
  }

  async findOne(id: number): Promise<UserResponse | null> {
    return await this.prisma.user.findFirst({
      where: {
        id,
      },
    });
  }

  async update(id: number, dto: UserUpdateInput): Promise<UserResponse> {
    return await this.prisma.user.update({
      data: dto,
      where: {
        id,
      },
    });
  }

  async remove(id: number): Promise<UserResponse> {
    return await this.prisma.user.delete({
      where: {
        id,
      },
    });
  }
}

これでソースの変更は完了です。

動作検証

次のコマンドでサーバを起動します。

npm run start

GET で叩くとデータを返してくれます。

curl --location --request GET 'http://localhost:3000/user' \
--header 'Content-Type: application/json'
[
  {
    "id": 1,
    "email": "test@example.com",
    "name": "test user"
  }
]

POST で不正な形式(email のフォーマットが不正)で叩くとエラーを返します。

curl --location 'http://localhost:3000/user' \
--header 'Content-Type: application/json' \
--data '{
    "email": "xxx",
    "name": "xxx"
}'
{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "validation": "email",
      "code": "invalid_string",
      "message": "形式が不正です",
      "path": ["email"]
    }
  ]
}

正常系は次の通りです。

curl --location 'http://localhost:3000/user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "xxx@example.com",
    "name": "xxx"
}'
{
  "id": 2,
  "email": "xxx@example.com",
  "name": "xxx"
}

OpenAPI の組み込み

さらに@nestjs/swaggerモジュールを利用して OpenAPI のドキュメントを自動生成するように設定します。

まず @nestjs/swagger をインストールします。

npm install --save @nestjs/swagger

src/main.ts

http://localhost:3000/docs にて Swagger の Viewer が表示されるように調整します。

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { INestApplication } from "@nestjs/common";

const createDocument = (app: INestApplication) => {
  const config = new DocumentBuilder()
    .setTitle("test")
    .setVersion("1.0")
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("docs", app, document);
};

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  createDocument(app);
  await app.listen(3000);
}
bootstrap();

src/user/user.controller.ts

Swagger 用のデコレータを追加してオブジェクトが表示されるように修正します。

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UsePipes,
} from "@nestjs/common";
import { UserService } from "./user.service";
import {
  UserCreateInputSchema,
  UserCreateInputDto,
  UserUpdateInputDto,
  UserResponseSchema,
} from "./entities/user.entity";
import { ZodValidationPipe, zodToOpenAPI } from "nestjs-zod";
import { ApiBody, ApiOkResponse, ApiTags } from "@nestjs/swagger";

@Controller("user")
@ApiTags("user")
@UsePipes(ZodValidationPipe)
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  @ApiBody({
    schema: zodToOpenAPI(UserCreateInputSchema),
  })
  @ApiOkResponse({
    schema: zodToOpenAPI(UserResponseSchema),
  })
  async create(@Body() dto: UserCreateInputDto) {
    return this.userService.create(dto);
  }

  @Get()
  @ApiOkResponse({
    schema: zodToOpenAPI(UserResponseSchema),
  })
  async findAll() {
    return this.userService.findAll();
  }

  @Get(":id")
  @ApiOkResponse({
    schema: zodToOpenAPI(UserResponseSchema),
  })
  async findOne(@Param("id") id: string) {
    return this.userService.findOne(+id);
  }

  @Patch(":id")
  @ApiBody({
    schema: zodToOpenAPI(UserCreateInputSchema),
  })
  @ApiOkResponse({
    schema: zodToOpenAPI(UserResponseSchema),
  })
  async update(@Param("id") id: string, @Body() dto: UserUpdateInputDto) {
    return this.userService.update(+id, dto);
  }

  @Delete(":id")
  @ApiOkResponse({
    schema: zodToOpenAPI(UserResponseSchema),
  })
  async remove(@Param("id") id: string) {
    return this.userService.remove(+id);
  }
}

参考にしたサイト

Tags
javascript(110)
node.js(54)
linux(54)
amazon%20aws(47)
typescript(45)
%E3%82%A2%E3%83%AB%E3%82%B4%E3%83%AA%E3%82%BA%E3%83%A0(36)
%E7%94%BB%E5%83%8F%E5%87%A6%E7%90%86(30)
html5(29)
php(24)
centos(24)
python(22)
%E7%AB%B6%E6%8A%80%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0(21)
mac(21)
mysql(20)
canvas(19)
opencv(17)
%E9%9B%91%E8%AB%87(16)
docker(16)
wordpress(15)
atcoder(14)
apache(12)
%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92(12)
%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9(12)
amazon%20s3(12)
red%20hat(12)
prisma(12)
ubuntu(11)
github(10)
git(10)
vue.js(10)
%E7%94%BB%E5%83%8F%E5%87%A6%E7%90%86100%E6%9C%AC%E3%83%8E%E3%83%83%E3%82%AF(10)
mariadb(10)
react(9)
aws%20cdk(9)
css3(8)
%E5%8F%AF%E8%A6%96%E5%8C%96(8)
%E5%B0%8F%E3%83%8D%E3%82%BF(8)
nestjs(8)
amazon%20lightsail(7)
next.js(7)
%E3%83%96%E3%83%AD%E3%82%B0(6)
cms(6)
oracle(6)
perl(6)
gitlab(6)
iam(5)
amazon%20ec2(5)
%E8%B3%87%E6%A0%BC%E8%A9%A6%E9%A8%93(5)
aws%20amplify(5)
curl(4)
Author
githubzennqiita
ただの備忘録です。

※外部送信に関する公表事項