0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NestJS + PrismaのPOST APIにDTOとValidationPipeを追加して入力チェックする

0
Posted at

はじめに

NestJS + PrismaでPOST APIを作ると、最初は @Body() で受け取った値をそのままPrismaに渡す形でも動かせます。

ただし、そのままだと以下のようなリクエストも受け取れてしまう可能性があります。

  • 必須項目が空
  • 文字列であるべき項目に数値が入っている
  • メールアドレス形式ではない
  • 想定していないプロパティが含まれている
  • 空文字のまま登録される

業務開発では、DB登録前にリクエストの入力値を確認しておきたいです。

この記事では、NestJS + PrismaのPOST APIにDTOと ValidationPipe を追加して、最小構成で入力チェックを試します。

なお、この記事は以前整理した「NestJS_Docker_PrismaでPostgreSQLに接続する最小構成を作ってみる」の続きとして、同じ UsersController / UsersService をベースにしています。DB接続部分のセットアップは、その記事を参照してください。

前提

この記事では、以下のようなPOST APIがすでにある想定です。

POST /users

登録するデータは、例として以下にします。

name
email

Prismaのモデルは、この記事では以下の形にします。

prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

以前の最小構成の記事では name を省略可能な String? にしていましたが、今回は入力チェックの例として必須項目にしています。

また、更新日時を追跡できるように updatedAt も追加しています。

今回修正するファイル

この記事では、主に以下を修正します。

prisma/schema.prisma
src/main.ts
src/users/dto/create-user.dto.ts
src/users/users.controller.ts
src/users/users.service.ts

Prisma schemaを修正する

まず、prisma/schema.prismaUser モデルを今回の記事内容に合わせます。

prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

ポイントは以下です。

  • nameString? ではなく String にする
  • email@unique を付ける
  • createdAt@default(now()) にする
  • updatedAt@updatedAt にする

name を必須にしておくことで、後で作成するDTOの @IsNotEmpty() と合わせて、API入力とDB定義の意図をそろえやすくなります。

DBにschema変更を反映する

schema.prisma を修正したら、DBに反映します。

npx prisma migrate dev --name update-user-validation-sample

すでにDBとschemaが同期している場合は、以下のように表示されることがあります。

Already in sync, no schema change or pending migration was found.

これはエラーではありません。

現在の schema.prisma とDBの状態に差分がない、という意味です。

既存データがある場合の注意

もし既存DBに namenull のデータがある状態で、name String? から name String に変更すると、migrationで失敗する可能性があります。

検証用DBでデータを消してもよい場合は、以下でリセットできます。

npx prisma migrate reset

ただし、DBのデータが削除されるため、必要なデータがある場合は注意してください。

Prisma Clientを生成する

通常は migrate dev の中でPrisma Clientも生成されますが、必要に応じて以下も実行します。

npx prisma generate

最初の実装例

まず、入力チェックを入れる前の例です。

src/users/users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() body: { name: string; email: string }) {
    return this.usersService.create(body);
  }
}
src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

type CreateUserInput = {
  name: string;
  email: string;
};

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

  async create(input: CreateUserInput) {
    return this.prisma.user.create({
      data: input,
    });
  }
}

この状態でも、正しいJSONを送れば登録できます。

{
  "name": "Taro",
  "email": "taro@example.com"
}

しかし、次のような値に対するチェックはまだ弱いです。

{
  "name": "",
  "email": "not-email",
  "role": "admin"
}

そこでDTOと ValidationPipe を追加します。

パッケージを追加する

NestJSの ValidationPipe でDTOのバリデーションを行う場合、class-validatorclass-transformer を使います。

役割はそれぞれ異なります。

  • class-validator:DTOのプロパティにデコレーターでルールを書き、入力値を検証する
  • class-transformer:リクエストbodyのプレーンなオブジェクトを、DTOクラスのインスタンスに変換する

ValidationPipe はこの2つを組み合わせて、リクエストbodyをDTOクラスに変換したうえで検証します。

npm install class-validator class-transformer

DTOを作成する

src/users/dto/create-user.dto.ts を作成します。

src/users/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(50)
  name!: string;

  @IsEmail()
  @IsNotEmpty()
  @MaxLength(255)
  email!: string;
}

ここでは、以下をチェックしています。

  • name は文字列
  • name は空にしない
  • name は最大50文字
  • email はメール形式
  • email は空にしない
  • email は最大255文字

@IsString()@IsNotEmpty() は役割が異なります。

@IsString() だけでは空文字("")も「文字列」として通ってしまうため、空文字を弾きたい場合は @IsNotEmpty() も併用します。

! を付けている理由

TypeScriptの strictPropertyInitialization が有効な場合、DTOのプロパティに初期値がないと以下のようなエラーになることがあります。

Property 'name' has no initializer and is not definitely assigned in the constructor.

NestJSでは、リクエストbodyからDTOのプロパティがセットされるため、このサンプルでは definite assignment assertion の ! を付けています。

name!: string;
email!: string;

なぜinterfaceではなくclassなのか

DTOは、TypeScriptの interface ではなく class として定義します。

interface はコンパイル後のJavaScriptには残らないため、class-validator のデコレーターによる実行時の検証対象として使えません。

そのため、NestJSで ValidationPipeclass-validator を使って入力チェックする場合は、DTOを class として定義します。

ControllerでDTOを使う

@Body() の型をDTOに変更します。

src/users/users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

これで、Controller上では「POST bodyとして何を受け取るか」が明確になります。

ValidationPipeを有効化する

DTOを作っただけでは、入力チェックは有効になりません。

main.tsValidationPipe を有効化します。

src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

設定の意味は以下です。

オプション 意味
whitelist: true DTOに定義されていないプロパティを除外する
forbidNonWhitelisted: true DTOにないプロパティが送られたらエラーにする
transform: true リクエストbodyをDTOクラスのインスタンスに変換する

whitelistforbidNonWhitelisted は、想定外のプロパティを受け取らないために便利です。

たとえば、以下のようなリクエストを防ぎやすくなります。

{
  "name": "Taro",
  "email": "taro@example.com",
  "role": "admin"
}

Service側の型をDTOに合わせる

Service側もDTOを受け取るようにします。

src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';

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

  async create(createUserDto: CreateUserDto) {
    return this.prisma.user.create({
      data: createUserDto,
    });
  }
}

最小構成ではこのままでも動きます。

ただし、実務ではDTOをそのままPrismaに渡すかどうかは、内容によって判断した方がよいです。

たとえば、以下のような場合はService内でPrismaに渡す値を明示的に組み立てる方が安全です。

  • DTOにDB保存しない項目が含まれる
  • パスワードのハッシュ化が必要
  • ログインユーザーIDを追加する
  • enumや日付の変換が必要
  • 関連テーブルのconnectが必要
  • API入力値とDB保存形式が異なる

例:DTOに確認用のパスワード入力(passwordConfirm)が含まれている場合、DB保存時はパスワードのハッシュ化が必要で、passwordConfirm 自体は保存しません。

src/users/dto/create-user.dto.ts(例)
export class CreateUserDto {
  // ...
  @IsString()
  password!: string;

  @IsString()
  passwordConfirm!: string;
}
src/users/users.service.ts
async create(createUserDto: CreateUserDto) {
  // 実際にはbcryptなどを使ってハッシュ化する
  const hashedPassword = await hash(createUserDto.password);

  return this.prisma.user.create({
    data: {
      name: createUserDto.name,
      email: createUserDto.email,
      password: hashedPassword,
      // passwordConfirm はDBに保存しないため、ここには含めない
    },
  });
}

このように、DTOをそのまま data に渡すのではなく、DBに保存する項目だけを明示的に組み立てます。

今回の name / email だけのサンプルでは、結果的に全項目を渡す形と同じになりますが、以下のように明示しておくと、後から項目が増えたときに「DTOの全項目をそのままDBに保存していないか」を確認しやすくなります。

src/users/users.service.ts
async create(createUserDto: CreateUserDto) {
  return this.prisma.user.create({
    data: {
      name: createUserDto.name,
      email: createUserDto.email,
    },
  });
}

個人的には、最初は少し冗長でも、Prismaに渡す data を明示した方がレビューしやすいと感じます。

DTOの項目とPrismaに渡す値が1対1で対応しているかをコードレビューで確認しやすく、DTOに項目が増えたときも「この項目は保存対象か」をその場で判断しやすくなります。

動作確認

開発サーバーを起動します。

npm run start:dev

この記事では、curlコマンドはWindowsのコマンドプロンプト(cmd)向けに記載します。

正常系です。

curl -X POST http://localhost:3000/users ^
  -H "Content-Type: application/json" ^
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\"}"

email@unique を付けているため、同じメールアドレスで2回実行するとPrisma側の一意制約エラーになります。

再実行する場合は、taro2@example.com のように別のメールアドレスに変更してください。

curl -X POST http://localhost:3000/users ^
  -H "Content-Type: application/json" ^
  -d "{\"name\":\"Taro\",\"email\":\"taro2@example.com\"}"

バリデーションエラーになる例です。

curl -X POST http://localhost:3000/users ^
  -H "Content-Type: application/json" ^
  -d "{\"name\":\"\",\"email\":\"not-email\"}"

レスポンス例です。

{
  "message": [
    "name should not be empty",
    "email must be an email"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

DTOにないプロパティを送る例です。

curl -X POST http://localhost:3000/users ^
  -H "Content-Type: application/json" ^
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\",\"role\":\"admin\"}"

forbidNonWhitelisted: true を設定しているため、role はエラーになります。

レスポンス例です。

{
  "message": [
    "property role should not exist"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

DB登録結果を確認する

登録結果はPrisma Studioで確認できます。

npx prisma studio

ブラウザでPrisma Studioが開いたら、User テーブルを確認します。

また、PostgreSQLに直接入って確認する場合は、PostgreSQLコンテナ名を確認します。

docker ps

コンテナ名が sample-postgres の場合は、以下のように接続します。

docker exec -it sample-postgres psql -U postgres -d nest_prisma_sample

psqlに入ったら、以下で確認できます。

\dt
SELECT * FROM "User";
\d "User"

終了する場合は以下です。

\q

正常系で500エラーになった場合

正常系のリクエストで以下のようなレスポンスになる場合があります。

{
  "statusCode": 500,
  "message": "Internal server error"
}

リクエストbodyが正しいのに500エラーになる場合は、DTOのバリデーションではなく、PrismaやDB側でエラーになっている可能性があります。

たとえば、今回のモデルでは email@unique を付けているため、同じメールアドレスを2回登録しようとするとPrisma側の一意制約エラーになります。

その場合は、別のメールアドレスで再実行してください。

curl -X POST http://localhost:3000/users ^
  -H "Content-Type: application/json" ^
  -d "{\"name\":\"Taro\",\"email\":\"taro3@example.com\"}"

それでも500エラーになる場合は、NestJSを起動しているターミナルのログを確認します。

以下のようなエラーが出ている場合は、DB接続やmigrationの状態を確認します。

Unique constraint failed
Can't reach database server
Environment variable not found: DATABASE_URL
The table `public.User` does not exist

DTOとPrismaの型は役割が違う

Prismaを使っていると、Prisma Clientが型を生成してくれます。

そのため、最初は次のように思うかもしれません。

Prismaの型があるなら、DTOはいらないのでは?

ただ、DTOとPrismaの型は見ている場所が違います。

DTOは、APIに入ってくるリクエストbodyの形を定義するものです。

一方で、Prismaの型は、Prismaを使ってDB操作するときの型を安全にするものです。

たとえば、今回のPOST APIでは、外部から以下のようなJSONが送られてきます。

{
  "name": "Taro",
  "email": "taro@example.com"
}

このリクエストに対して、DTOでは以下のようなルールを定義します。

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(50)
  name!: string;

  @IsEmail()
  @IsNotEmpty()
  @MaxLength(255)
  email!: string;
}

ここで確認しているのは、APIに入ってきた値が、

  • 文字列か
  • 空ではないか
  • メールアドレス形式か
  • 最大文字数を超えていないか
  • 想定外のプロパティを含んでいないか

といった内容です。

つまりDTOは、外部から入ってくる値を、アプリケーションで扱ってよい形か確認するためのものです。

一方で、Prismaの型はDB操作時に効きます。

たとえば、Prisma Clientを使って以下のように登録する場合、

return this.prisma.user.create({
  data: {
    name: createUserDto.name,
    email: createUserDto.email,
  },
});

Prismaは、User テーブルに存在しないカラムを指定していないか、必須項目が足りているか、型が合っているかなどをTypeScript上でチェックしてくれます。

つまりPrismaの型は、DBに対して正しい形で操作できているかを確認するためのものです。

整理すると、以下のようになります。

種類 主な役割
DTO APIで受け取る入力値の形を定義する
ValidationPipe DTOに書いたルールでリクエストを検証する
Prismaの型 DB操作の型安全性を高める

DTOは「APIの入口」のチェックです。

Prismaの型は「DB操作」のチェックです。

そのため、Prismaの型があるからDTOが不要になる、というわけではありません。

たとえば、Prismaの emailString だったとしても、それだけでは not-email のような文字列を防げません。
DB上はただの文字列として扱えるためです。

{
  "name": "Taro",
  "email": "not-email"
}

このような値をAPIの入口で弾くには、DTO側で @IsEmail() を付ける必要があります。

また、Prismaの型があっても、リクエストに含まれる想定外のプロパティをどう扱うかは、DTOと ValidationPipe 側で考える必要があります。

{
  "name": "Taro",
  "email": "taro@example.com",
  "role": "admin"
}

このような role を受け取らないようにするには、ValidationPipewhitelistforbidNonWhitelisted を設定します。

このように、DTOとPrismaの型は似ているように見えて、担当している責務が違います。

  • APIに入ってくる値を確認するのがDTO
  • そのDTOを使ってDBに保存する処理を型安全にするのがPrisma

と考えると分かりやすいです。

よくある注意点

DTOを作っただけではチェックされない

DTOに @IsEmail()@IsNotEmpty() を書いても、ValidationPipe を有効化していないとチェックされません。

main.ts の設定を忘れないようにします。

importを忘れない

DTOで使うデコレーターは class-validator からimportします。

import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';

空文字を許したくない場合は @IsNotEmpty() を付ける

@IsString() だけでは、空文字は弾けません。

空文字を登録したくない場合は、@IsNotEmpty() も付けます。

DTOにない値を弾くなら forbidNonWhitelisted を使う

whitelist: true だけだと、DTOにないプロパティは除外されます。

不正な値が送られたこと自体をエラーにしたい場合は、forbidNonWhitelisted: true も使います。

実務で考えたいこと

今回のサンプルは最小構成です。

実務では、さらに以下のような観点も考えます。

  • エラーメッセージを日本語化するか(利用者向けの画面で直接表示する場合)
  • 画面側で同じバリデーションを持つか(サーバーへの通信前にUX上のフィードバックを出すため)
  • DTOと画面フォームの型をどう合わせるか(フロントとバックエンドで定義がズレると、片方の修正漏れが起きやすいため)
  • パスワードなどをDTOからPrismaへそのまま渡さない(ハッシュ化前の値を誤って保存しないため)
  • CreateDtoUpdateDto を分ける(更新時は一部項目だけ送られることが多く、必須チェックの基準が作成時と変わるため)
  • PATCH では @IsOptional() を使う(送られなかった項目まで必須エラーにしないため)
  • 重複エラーなどDB制約エラーをどう返すか(@unique制約違反はDTOバリデーションでは防げず、Prisma側のエラーとして別途扱う必要があるため)
  • API仕様書にDTOをどう反映するか(フロントエンドの実装者がリクエスト形式を都度ソースコードで確認しなくて済むようにするため)

たとえば、今回のサンプルで同じ email を持つユーザーを2回登録しようとすると、DTOのバリデーションはすべて通過したうえで、Prisma側の @unique 制約違反エラーになります。

これはDTOでは防げないため、別途エラーハンドリングを用意する必要があります。

入力チェックは、フロントエンドだけでなくバックエンド側にも必要です。

フロント側でチェックしていても、APIは直接呼ばれる可能性があります。

そのため、POST APIではサーバー側でも入力値を検証しておく方が安全です。

まとめ

NestJS + PrismaのPOST APIにDTOと ValidationPipe を追加すると、APIの入力チェックを整理しやすくなります。

今回確認したことは以下です。

  • schema.prisma でDB側の必須項目も整理する
  • DTOでリクエストbodyの形を定義する
  • class-validator のデコレーターで入力ルールを書く
  • ValidationPipe を有効化してDTOの検証を実行する
  • whitelist / forbidNonWhitelisted で想定外のプロパティを扱う
  • DTOとPrismaの型は役割が違う
  • Service側ではPrismaに渡す値を明示するとレビューしやすい

最初は少し手間に見えますが、DTOを用意しておくと、Controllerが何を受け取るのかが分かりやすくなります。

また、入力チェックの責務を整理できるため、POST APIの保守もしやすくなると感じています。

サンプルコード

この記事で確認したサンプルコードは以下に置いています。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?