事の発端
個人でタスク管理アプリを作ろうと思い、NestJS + GraphQLに挑戦することに。「まずはタスク作成のAPIから作るか」と意気込んでDTOを書きました。
import { IsNotEmpty, IsString } from 'class-validator';
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class CreateTaskInput {
@Field()
@IsNotEmpty()
@IsString()
name: string;
@Field()
@IsNotEmpty()
dueDate: string;
@Field({ nullable: true })
@IsOptional()
description?: string;
}
よし、これで空文字は弾かれるはず!と思ってPostmanでテスト...
絶望の瞬間
POST /graphql
{
"query": "mutation { createTask(input: { name: \"\", dueDate: \"\" }) { id name } }"
}
結果...
{
"data": {
"createTask": {
"id": "1",
"name": "" // 😱 空文字が通ってる...
}
}
}
は?なんで???
ググりまくりの始まり
疑ったポイント1: デコレーターの書き方
「NestJS バリデーション 効かない」でGoogle検索しまくり...
// もしかしてこう?
@IsNotEmpty()
@Field()
name: string;
// それともこう?
@Field()
@IsNotEmpty()
name: string;
結果: どっちでも変わらず
疑ったポイント2: パッケージが足りない?
Stack Overflowで「class-validatorが必要」という記事を発見。
npm install class-validator class-transformer
結果: すでに入ってた
疑ったポイント3: GraphQLだから効かない?
「もしかしてGraphQLでは別の書き方?」と思ってREST APIでも試してみる。
// REST APIでも試してみる
@Post()
createTask(@Body() input: CreateTaskInput) {
console.log('input:', input); // { name: "", dueDate: "" }
}
結果: RESTでも効かない!
ついに答えを発見
もう諦めかけた時、Qiitaの記事で救いの一文を発見...
「NestJSでバリデーションを使うにはValidationPipeの設定が必要です」
え、そんなの必要だったの!?
真犯人発見
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; // ← これを追加
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// ← この1行が抜けてた!!!
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
設定後のテスト
再び同じリクエストを送信...
{
"errors": [
{
"message": "Bad Request Exception",
"extensions": {
"code": "BAD_USER_INPUT",
"response": {
"statusCode": 400,
"message": [
"name should not be empty",
"dueDate should not be empty"
],
"error": "Bad Request"
}
}
}
]
}
キターーーーー!!! 🎉
なぜハマったのか
実はNestJSでは:
リクエスト → Pipe → Controller → Service
この流れで処理されるんですが、Pipeが設定されてないと検証が実行されないんです。
// DTOのデコレーターは「ルールの定義」
@IsNotEmpty() // ← 「空文字はダメ」というルール
// ValidationPipeは「ルールの実行者」
app.useGlobalPipes(new ValidationPipe()); // ← 「ルールをチェックして」
つまり、ルールブックを作っても審判(ValidationPipe)がいないと試合にならない状態だったわけです。
さらにハマったポイント
設定したは良いものの、今度はこんなエラーが...
Error: Cannot determine GraphQL input type for CreateTaskInput
なぜかGraphQLのスキーマ生成でコケる。調べてみると...
// ダメな例
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}));
GraphQLではtransform: true
を設定するとスキーマ生成でバグることがあるらしい。結局シンプルに:
app.useGlobalPipes(new ValidationPipe());
これで落ち着きました。
他の適用方法も試してみた
コントローラー単位
@Controller('tasks')
@UsePipes(ValidationPipe)
export class TaskController {
// このコントローラーだけバリデーション
}
メソッド単位
@Post()
createTask(@Body(ValidationPipe) input: CreateTaskInput) {
// このメソッドだけバリデーション
}
でも結局、グローバル設定が一番楽でした。
実際にやらかした時のバグ
ValidationPipeを設定する前、こんなコードを書いてました:
@Mutation(() => Task)
async createTask(@Args('input') input: CreateTaskInput) {
// input.name が空文字でもここに到達
const slug = input.name.toLowerCase().replace(/\s+/g, '-');
// ↑
// Cannot read property 'toLowerCase' of null
// → ローカルで500エラー!
}
開発中にエラーが出まくって「なんで???」状態でした...😭
学んだこと
- DTOだけ書いてもバリデーションは効かない
- ValidationPipeの設定は必須
- main.tsでグローバル設定が基本
- GraphQLでは設定オプションに注意
今では当たり前に設定してること
// main.ts(テンプレート化した)
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// バリデーション(必須)
app.useGlobalPipes(new ValidationPipe());
// CORS(開発時)
app.enableCors();
await app.listen(3000);
}
最後に
ValidationPipeの設定だけは絶対に忘れずにと肝に銘じておきます
「なんでバリデーション効いてないの?」って2時間悩むのは結構しんどいです...😅
追記:よく使うバリデーションルール
import {
IsNotEmpty,
IsString,
IsEmail,
IsOptional,
MinLength,
MaxLength,
IsDateString
} from 'class-validator';
@InputType()
export class CreateUserInput {
@Field()
@IsNotEmpty({ message: '名前は必須です' })
@IsString()
@MinLength(2, { message: '名前は2文字以上で入力してください' })
@MaxLength(50)
name: string;
@Field()
@IsEmail({}, { message: '正しいメールアドレスを入力してください' })
email: string;
@Field({ nullable: true })
@IsOptional()
@IsDateString()
birthDate?: string;
}