NestJS入門 メモ書き
プロジェクト作成
$ nest new プロジェクト名
パッケージ管理ツールを選択
yarnだとエラーが起きたのでnpmを選択
messagesというプロジェクト名で作成した。
コマンドからモジュールを作成できる。
$ nest generate module messages
コントローラーもコマンドで作成できる。
--flat
をつけないとmessagesフォルダの下にcontrollersというフォルダを作成した上でファイルを作成する。
$ nest generate controller messages/messages --flat
実際に実行するとこんな感じ
work/nestjs/messages on 🐈 master [?] via ⬢ v14.15.4
at 19:15:48 ❯ nest generate module messages
CREATE src/messages/messages.module.ts (85 bytes)
work/nestjs/messages on 🐈 master [?] via ⬢ v14.15.4 took 3s
at 19:15:57 ❯ nest generate controller messages/messages --flat
CREATE src/messages/messages.controller.spec.ts (506 bytes)
CREATE src/messages/messages.controller.ts (105 bytes)
UPDATE src/messages/messages.module.ts (182 bytes)
それぞれ作成、必要なインポートを追記してくれる。
Controllerにルーティングを書く
import { Controller, Get, Post } from '@nestjs/common';
@Controller('messages')
export class MessagesController {
@Get()
listMessages() {}
@Post()
createMessages() {}
@Get('/:id')
getMessage() {}
}
GET /messages
POST /messages
GET /messages/:id
にそれぞれが対応する。
リクエストのbodyとparamの取り扱い
import { Body, Param } from '@nestjs/common';
以下のように受け取る
@Post()
createMessages(@Body() body: any) {
console.log(body);
}
@Get('/:id')
getMessage(@Param('id') id: string) {
console.log(id);
}
NestJSではPipeというものを使ってバリデーションなど実装する
main.tsにPipeを追加
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; // ここと
import { MessagesModule } from './messages/messages.module';
async function bootstrap() {
const app = await NestFactory.create(MessagesModule);
app.useGlobalPipes(new ValidationPipe()); // ここ
await app.listen(3000);
}
bootstrap();
DTOを作る。ここまでの構成は以下のような感じ
src
├── main.ts
└── messages
├── dtos
│ └── create-message.dto.ts
├── messages.controller.spec.ts
├── messages.controller.ts
└── messages.module.ts
DTOの中身は以下のような感じ
import { IsString } from 'class-validator';
export class CreateMessageDto {
@IsString()
content: string;
}
※npm install class-validator class-transformer
を実行しておく
⇨JSONデータをDTOクラスに変換してバリデーションするイメージ?
DTOをコントローラーに設定する
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { CreateMessageDto } from './dtos/create-message.dto'; // ここ
@Controller('messages')
export class MessagesController {
@Get()
listMessages() {}
@Post()
createMessages(@Body() body: CreateMessageDto) { // ここ
console.log(body);
}
@Get('/:id')
getMessage(@Param('id') id: string) {
console.log(id);
}
}
以下のようなbodyをPOSTすると
{
"content": 111
}
ちゃんとエラーが返ってくる
{
"statusCode": 400,
"message": [
"content must be a string"
],
"error": "Bad Request"
}
serviceとrepositoryを作成する。
DBなどへの処理をrepositoryに書いて、
serviceからそれを実行するイメージ
import { readFile, writeFile } from 'fs/promises';
export class MessagesRepository {
async findOne(id: string) {
const contents = await readFile('messages.json', 'utf8');
const messages = JSON.parse(contents);
return messages[id];
}
async findAll() {
const contents = await readFile('messages.json', 'utf8');
const messages = JSON.parse(contents);
return messages;
}
async create(content: string) {
const contents = await readFile('messages.json', 'utf8');
const messages = JSON.parse(contents);
const id = Math.floor(Math.random() * 999);
messages[id] = { id, content };
await writeFile('messages.json', JSON.stringify(messages));
}
}
import { MessagesRepository } from './messages.repository';
export class MessagesService {
messagesRepo: MessagesRepository;
constructor() {
// 確認のためこのように一旦実装する
// serviceとrepositoryに依存関係ができてしまうので実際の現場ではこのような書き方はすべきではない
this.messagesRepo = new MessagesRepository();
}
findOne(id: string) {
return this.messagesRepo.findOne(id);
}
findAll() {
return this.messagesRepo.findAll();
}
create(content: string) {
return this.messagesRepo.create(content);
}
}
そしてcontrollerからserviceを呼び出す
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { CreateMessageDto } from './dtos/create-message.dto';
import { MessagesService } from './messages.service';
@Controller('messages')
export class MessagesController {
messagesService: MessagesService;
constructor() {
// 確認のためこのように一旦実装する
// 依存関係ができてしまうので実際の現場ではこのような書き方はすべきではない
this.messagesService = new MessagesService();
}
@Get()
listMessages() {
return this.messagesService.findAll();
}
@Post()
createMessages(@Body() body: CreateMessageDto) {
return this.messagesService.create(body.content);
}
@Get('/:id')
getMessage(@Param('id') id: string) {
return this.messagesService.findOne(id);
}
}
このままだと例えば存在しないidで取得しようとしてもエラーが発生しない。その場合
.
.
NotFoundException,
} from '@nestjs/common';
を使って
.
.
@Get('/:id')
async getMessage(@Param('id') id: string) {
const message = await this.messagesService.findOne(id);
if (!message) {
throw new NotFoundException('message not found');
}
return message;
}
.
.
とすることでちゃんとエラーを返せる。
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 68
ETag: W/"44-pb604VmiFWA/9W7nuxCAKFS85bQ"
Date: Tue, 09 Nov 2021 06:06:39 GMT
Connection: close
{
"statusCode": 404,
"message": "message not found",
"error": "Not Found"
}
controller, service, repository間での依存関係をリファクタリング
まず内部でインスタンスを作るのではなく、
作成済みのインスタンスを受け取るようにする。
import { MessagesRepository } from './messages.repository';
export class MessagesService {
// ここ
constructor(public messagesRepo: MessagesRepository) {
this.messagesRepo = messagesRepo;
}
findOne(id: string) {
return this.messagesRepo.findOne(id);
}
findAll() {
return this.messagesRepo.findAll();
}
create(content: string) {
return this.messagesRepo.create(content);
}
}
.
.
export class MessagesController {
constructor(public messagesService: MessagesService) {
this.messagesService = messagesService;
}
.
.
そしてrepositoryとserviceのクラスにInjectableというデコレーターをつける。
いわゆる依存性の注入。
import { Injectable } from '@nestjs/common';
.
.
@Injectable()
export class MessagesRepository {
import { Injectable } from '@nestjs/common';
.
.
@Injectable()
export class MessagesRepository {
moduleにprovidersを設定することでうまく依存関係を解決してくれる。
import { Module } from '@nestjs/common';
import { MessagesController } from './messages.controller';
import { MessagesRepository } from './messages.repository';
import { MessagesService } from './messages.service';
@Module({
controllers: [MessagesController],
// ここ
providers: [MessagesService, MessagesRepository],
})
export class MessagesModule {}
service, controller, moduleなどコマンドで簡単に作れる、ショートハンドもある
$ nest g service serviceName
$ nest g controller controllerName
$ nest g module moduleName
まずはmoduleを作成して、その後に同名でcontrollerやserviceを作るとうまく階層構造に自動でファイルを作ってくれる。
1001 nest g module computer
1002 nest g module cpu
1003 nest g module disk
1004 nest g module power
1005 nest g service cpu
1006 nest g service power
1007 nest g service disk
1008 nest g controller computer
at 18:40:59 ❯ tree src
src
├── computer
│ ├── computer.controller.spec.ts
│ ├── computer.controller.ts
│ └── computer.module.ts
├── cpu
│ ├── cpu.module.ts
│ ├── cpu.service.spec.ts
│ └── cpu.service.ts
├── disk
│ ├── disk.module.ts
│ ├── disk.service.spec.ts
│ └── disk.service.ts
├── main.ts
└── power
├── power.module.ts
├── power.service.spec.ts
└── power.service.ts
上のような多数のmoduleがある場合
serviceを外部のmoduleから使うためにexportsが必要
import { Module } from '@nestjs/common';
import { PowerService } from './power.service';
@Module({
providers: [PowerService],
exports: [PowerService], // ここ
})
export class PowerModule {}
逆に使う側ではimportsを設定する
import { Module } from '@nestjs/common';
import { PowerModule } from 'src/power/power.module';
import { CpuService } from './cpu.service';
@Module({
imports: [PowerModule], // ここ
providers: [CpuService],
})
export class CpuModule {}
importsしたことでservice内で利用できる。
import { Injectable } from '@nestjs/common';
import { PowerService } from 'src/power/power.service';
@Injectable()
export class CpuService {
constructor(private powerService: PowerService) {}
compute(a: number, b: number) {
console.log('Drawing 10 watts of power from Power Service');
this.powerService.supplyPower(10);
return a + b;
}
}
DBとのコネクションにはTypeORMを使うのが良さそう。
お試しsqlite3
$ npm install @nestjs/typeorm typeorm sqlite3
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; // これ
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
@Module({
imports: [
TypeOrmModule.forRoot({ // ここ
type: 'sqlite',
database: 'db.sqlite',
entities: [],
synchronize: true,
}),
UsersModule,
ReportsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
$ npm run start:dev
で稼働させると自動でdb.sqliteが作成される。
Entityを作ってみる。
こんな感じ。クラスにデコレーターをつけていく。
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
password: string;
}
作ったEntityをmoduleでimportする。
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // ここ
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Appモジュールでもentitiesに設定する。
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
import { User } from './users/user.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'db.sqlite',
entities: [User], // ここ
synchronize: true,
}),
UsersModule,
ReportsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
※synchronizeオプションはDBのmigrationを自動でするか否かの設定
Entityの変更を即座にDBへ反映してくれる。
開発系などテストをする場合は良いが本番時には注意する。
ユーザ登録APIを作ってみる。
リクエストbodyのバリデーションのためPipeを使う。
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes( // ここ
new ValidationPipe({
whitelist: true,
}),
);
await app.listen(3000);
}
bootstrap();
※whitelist: trueにしておくとユーザが余計なフィールドをリクエストbodyに入れても無視してくれる。
例えばDTOでemailとpasswordだけをこのあと定義するが
### Create a new user
POST http://localhost:3000/auth/signup
Content-Type: application/json
{
"email": "test@test.com",
"password": "testpass123"
"admin": true,
}
のようなリクエストをしてもサーバ側では
{ email: 'test@test.com', password: 'testpass123' }
しか受け取らない。
Pipeを使うのにライブラリを入れてくれとエラーが出るので入れる。
$ npm install class-validator class-transformer
DTOの作成
import { IsEmail, IsString } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
controllerにルートを作成
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
@Controller('auth')
export class UsersController {
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
console.log(body);
}
}
TypeORMのrepositoryを使っての実際の登録処理を追加する。
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {} // ここ
create(email: string, password: string) { // 登録処理
/* まずはUser Entityを作る。ここでバリデーションが働く */
const user = this.repo.create({ email, password });
/* 直接bodyからobjectを作って入れることもできるが上で作った User Entityを引数に登録する */
return this.repo.save(user);
}
}
また一度Entityをcreateしてからsaveすることでentity内で特定のアクション時に処理を実行させることができる。
import {
Entity,
Column,
PrimaryGeneratedColumn,
AfterInsert,
AfterRemove,
AfterUpdate,
} from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
password: string;
@AfterInsert() // ここ
logInsert() {
console.log('Inserted User with id', this.id);
}
@AfterRemove() // ここ
logRemove() {
console.log('Removed User with id', this.id);
}
@AfterUpdate() // ここ
logUpdate() {
console.log('Updated User with id', this.id);
}
}
controllerから呼び出す
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { UsersService } from './users.service';
@Controller('auth')
export class UsersController {
constructor(private usersService: UsersService) {}
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
this.usersService.create(body.email, body.password);
}
}
ユーザデータの取得、更新、削除の処理も作る。
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {}
create(email: string, password: string) {
const user = this.repo.create({ email, password });
return this.repo.save(user);
}
findOne(id: number) {
return this.repo.findOne(id);
}
find(email: string) {
return this.repo.find({ email });
}
async update(id: number, attrs: Partial<User>) {
// 一度User Entityを取得
const user = await this.findOne(id);
if (!user) {
throw new Error('user not found');
}
// フィールドを更新
Object.assign(user, attrs);
// 再度登録
return this.repo.save(user);
}
async remove(id: number) {
const user = await this.findOne(id);
if (!user) {
throw new Error('user not found');
}
return this.repo.delete(user);
}
}
- 取得系は簡単
- updateでは一部フィールドだけを更新することを考慮して
Partial<User>
という風にデータを受け取る。 - updateでもremoveでも一度User Entityを取得してからそれぞれ処理を行う
それぞれコントローラに設定
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUserDto } from './dtos/update-user.dto';
import { UsersService } from './users.service';
@Controller('auth')
export class UsersController {
constructor(private usersService: UsersService) {}
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
this.usersService.create(body.email, body.password);
}
@Get('/:id')
findUser(@Param('id') id: string) {
return this.usersService.findOne(parseInt(id));
}
@Get()
findAllUsers(@Query('email') email: string) {
return this.usersService.find(email);
}
@Delete('/:id')
removeUser(@Param('id') id: string) {
return this.usersService.remove(parseInt(id));
}
@Patch('/:id')
updateUser(@Param('id') id: string, @Body() body: UpdateUserDto) {
return this.usersService.update(parseInt(id), body);
}
}
updateUserではbodyにDTOを設定するけどCreateUserDtoを設定すると全フィールドがないと怒られる。
update用のDTOを作る。
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class UpdateUserDto {
@IsEmail()
@IsOptional() // これをつけるとなくても良いフィールドになる
email: string;
@IsString()
@IsOptional() // これをつけるとなくても良いフィールドになる
password: string;
}
取得、更新、削除の処理で該当IDのユーザが存在しない場合のエラーハンドリングを追加すると以下のような感じ。
import { Injectable, NotFoundException } from '@nestjs/common';
.
.
async update(id: number, attrs: Partial<User>) {
// 一度User Entityを取得
const user = await this.findOne(id);
if (!user) {
throw new NotFoundException('user not found');
}
// フィールドを更新
Object.assign(user, attrs);
// 再度登録
return this.repo.save(user);
}
async remove(id: number) {
const user = await this.findOne(id);
if (!user) {
throw new NotFoundException('user not found');
}
return this.repo.delete(user);
}
.
.
取得はコントローラー側で行う
.
.
@Get('/:id')
async findUser(@Param('id') id: string) {
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
.
.
ユーザデータ取得時にpasswordフィールドが返ってくるのはよろしくない。
entityにデコレーターをつける
import { Exclude } from 'class-transformer';
import {
Entity,
Column,
PrimaryGeneratedColumn,
AfterInsert,
AfterRemove,
AfterUpdate,
} from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
@Exclude() // これ
password: string;
@AfterInsert()
logInsert() {
console.log('Inserted User with id', this.id);
}
@AfterRemove()
logRemove() {
console.log('Removed User with id', this.id);
}
@AfterUpdate()
logUpdate() {
console.log('Updated User with id', this.id);
}
}
controller側にも設定を追加
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
.
.
@UseInterceptors(ClassSerializerInterceptor)
@Get('/:id')
async findUser(@Param('id') id: string) {
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
.
.
上のはすごく簡易的。
もっとちゃんとデータをシリアライズするには自分でカスタマイズして作る。
import {
UseInterceptors,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';
export class SerializeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
// リクエストハンドラーによってリクエストがハンドルされる前に何かする処理
console.log("I'm running before the handler", context);
return handler.handle().pipe(
map((data: any) => {
// レスポンスが返される前に何かする処理
console.log("I'm running before response is sent out", data);
}),
);
}
}
上のようなファイルを作って、controllerで読み込む。
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { SerializeInterceptor } from 'src/interceptors/serialize.interceptor';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUserDto } from './dtos/update-user.dto';
import { UsersService } from './users.service';
@Controller('auth')
export class UsersController {
constructor(private usersService: UsersService) {}
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
this.usersService.create(body.email, body.password);
}
@UseInterceptors(SerializeInterceptor) // ここ
@Get('/:id')
async findUser(@Param('id') id: string) {
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
.
.
以下のようにDTOを作って公開したいフィールドにexposeをつける
import { Expose } from 'class-transformer';
export class UserDto {
@Expose()
id: number;
@Expose()
email: string;
}
シリアライザーではdataをUserDtoクラスに変換して返すようにする。
excludeExtraneousValuesをtrueにすることでDTOクラスのexposeの設定に従う。
import {
UseInterceptors,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';
import { UserDto } from 'src/users/dtos/user.dto';
export class SerializeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
// リクエストハンドラーによってリクエストがハンドルされる前に何かする処理
console.log("I'm running before the handler", context);
return handler.handle().pipe(
map((data: any) => {
// レスポンスが返される前に何かする処理
console.log("I'm running before response is sent out", data);
// データをUserDtoの公開設定に従って返す
return plainToClass(UserDto, data, {
excludeExtraneousValues: true,
});
}),
);
}
}
constructorでDTOを受け取るようにすると汎用性が増す
import {
UseInterceptors,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';
export class SerializeInterceptor implements NestInterceptor {
constructor(private dto: any) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
// リクエストハンドラーによってリクエストがハンドルされる前に何かする処理
console.log("I'm running before the handler", context);
return handler.handle().pipe(
map((data: any) => {
// レスポンスが返される前に何かする処理
console.log("I'm running before response is sent out", data);
// データをUserDtoの公開設定に従って返す
return plainToClass(this.dto, data, {
excludeExtraneousValues: true,
});
}),
);
}
}
使う側はこんな感じ
.
.
@UseInterceptors(new SerializeInterceptor(UserDto))
.
.
先にSerializeのデコレーターを作っておいてcontroller側ではそれを設定するようにするとBetter
.
.
export function Serialize(dto: any) {
return UseInterceptors(new SerializeInterceptor(dto));
}
.
.
.
.
@Serialize(UserDto)
.
.
なおcontrollerのトップに設置すると全てのUserデータ返り値に適用できる
.
.
@Controller('auth')
@Serialize(UserDto) // ここ
export class UsersController {
constructor(private usersService: UsersService) {}
.
.
Serializeをより型安全にするには以下のようにする。
.
.
interface ClassConstructor {
new (...args: any[]): {};
}
export function Serialize(dto: ClassConstructor) { // 何かしらのクラス
return UseInterceptors(new SerializeInterceptor(dto));
}
.
.
認証機能をつけてみる
usersの階層にauth.service.tsを追加
import { Injectable } from '@nestjs/common';
import { UsersService } from './users.service';
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
}
users.module.tsのプロバイダーに追加
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { AuthService } from './auth.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService, AuthService],
})
export class UsersModule {}
登録とサインイン処理を作るとこんな感じ
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { randomBytes, scrypt as _scrypt } from 'crypto';
import { promisify } from 'util';
const scrypt = promisify(_scrypt);
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
async signup(email: string, password: string) {
// 既存ユーザがいるかを探す
const users = await this.usersService.find(email);
if (users.length) {
throw new BadRequestException('email in use');
}
// パスワードハッシュ化
const salt = randomBytes(8).toString('hex');
const hash = (await scrypt(password, salt, 32)) as Buffer;
const result = salt + '.' + hash.toString('hex');
// ユーザを作成して登録
const user = await this.usersService.create(email, result);
// 作成したユーザを返す
return user;
}
async signin(email: string, password: string) {
// ユーザがいるか確認する
const [user] = await this.usersService.find(email);
if (!user) {
throw new NotFoundException('user not found');
}
// ハッシュ化したパスワードで一致を確認する
const [salt, storedHash] = user.password.split('.');
const hash = (await scrypt(password, salt, 32)) as Buffer;
if (storedHash === hash.toString('hex')) {
// 正しければユーザを返す
return user;
} else {
throw new BadRequestException('bad password');
}
}
}
コントローラに追加する
@Controller('auth')
@Serialize(UserDto)
export class UsersController {
constructor(
private usersService: UsersService,
private authService: AuthService,
) {}
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
return this.authService.signup(body.email, body.password);
}
@Post('/signin')
signin(@Body() body: CreateUserDto) {
return this.authService.signin(body.email, body.password);
}
Cookieセッションを実装する。
$ npm install cookie-session @types/cookie-session
ミドルウェアを追加
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
// Nestと相性が悪い?ためrequireで使用する
const cookieSession = require('cookie-session');
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// ミドルウェアをここで追加
app.use(
cookieSession({
keys: ['asdfasdf'],
}),
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
await app.listen(3000);
}
bootstrap();
認証時にセッションにユーザIDをセットする
@Post('/signup')
async createUser(@Body() body: CreateUserDto, @Session() session: any) {
const user = await this.authService.signup(body.email, body.password);
session.userId = user.id;
return user;
}
@Post('/signin')
async signin(@Body() body: CreateUserDto, @Session() session: any) {
const user = await this.authService.signin(body.email, body.password);
session.userId = user.id;
return user;
}
セッションを使ってユーザ情報取得とサインアウト処理
@Post('/signout')
signOut(@Session() session: any) {
session.userId = null;
}
@Get('/whoami')
whoAmI(@Session() session: any) {
return this.usersService.findOne(session.userId);
}
デコレータとインターセプターを使ってユーザ情報をリクエストに含めるようにする。
@Get('/whoami')
whoAmI(@CurrentUser() user: User) {
return user;
}
// @Get('/whoami')
// whoAmI(@Session() session: any) {
// return this.usersService.findOne(session.userId);
// }
デコレータとインターセプターを作る
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
// dataはCurrentUserの引数が入る。不要なのでneverにしておく
(data: never, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
// interceptorでユーザを取得してリクエストにセットしている
return request.currentUser;
},
);
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { UsersService } from '../users.service';
@Injectable()
export class CurrentUserInterceptor implements NestInterceptor {
constructor(private usersService: UsersService) {}
async intercept(context: ExecutionContext, handler: CallHandler) {
const request = context.switchToHttp().getRequest();
const { userId } = request.session || {};
if (userId) {
const user = await this.usersService.findOne(userId);
request.currentUser = user;
}
return handler.handle();
}
}
providerに追加
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { AuthService } from './auth.service';
import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService, AuthService, CurrentUserInterceptor], // ここ
})
export class UsersModule {}
各種コントローラーでinterceptorが機能する
@UseInterceptors(CurrentUserInterceptor) // ここ
export class UsersController {
constructor(
private usersService: UsersService,
private authService: AuthService,
) {}
グローバルに設定したい場合は以下のようにする
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { AuthService } from './auth.service';
import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [
UsersService,
AuthService,
// ここ
{
provide: APP_INTERCEPTOR,
useClass: CurrentUserInterceptor,
},
],
})
export class UsersModule {}
認証済みの場合のみ許可したいルートを設定する場合
guardを作る
import { CanActivate, ExecutionContext } from '@nestjs/common';
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
return request.session.userId;
}
}
作ったguardをコントローラに設定
@Get('/whoami')
@UseGuards(AuthGuard)
whoAmI(@CurrentUser() user: User) {
return user;
}
テストファイルの作成
こんな感じ
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { UsersService } from './users.service';
it('can create an instance of auth service', async () => {
// Partialを使って依存するサービスを型指定してフェイクのサービスを作成する
const fakeUsersService: Partial<UsersService> = {
find: () => Promise.resolve([]),
create: (email: string, password: string) =>
Promise.resolve({ id: 1, email, password } as User),
};
const module = await Test.createTestingModule({
providers: [
AuthService,
// UsersServiceを使うときは、fakeUserServiceを使ってね!という設定
{
provide: UsersService,
useValue: fakeUsersService,
},
],
}).compile();
const service = module.get(AuthService);
expect(service).toBeDefined();
});
より実践的にdescribeとbeforeEachを使って書く
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { UsersService } from './users.service';
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const fakeUsersService: Partial<UsersService> = {
find: () => Promise.resolve([]),
create: (email: string, password: string) =>
Promise.resolve({ id: 1, email, password } as User),
};
const module = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: fakeUsersService,
},
],
}).compile();
service = module.get(AuthService);
});
it('can create an instance of auth service', async () => {
expect(service).toBeDefined();
});
});
登録処理のテスト
it('creates a new user with a salted and hashed password', async () => {
const user = await service.signup('asdf@asdf.com', 'asdf');
// ハッシュ化されるので異なる
expect(user.password).not.toEqual('asdf');
// saltとhashは.で区切る仕様
const [salt, hash] = user.password.split('.');
expect(salt).toBeDefined();
expect(hash).toBeDefined();
});
使用済みのメールアドレスがある場合は登録に失敗することをテストする場合。
モック化したfakeUsersServiceをいじりたい。
宣言部分で外部に出しておくように変更
let fakeUsersService: Partial<UsersService>;
beforeEach(async () => {
fakeUsersService = {
find: () => Promise.resolve([]),
create: (email: string, password: string) =>
Promise.resolve({ id: 1, email, password } as User),
};
テスト部分ではfindを定義して既にユーザがいることを想定させる。
it('throws an error if user signs up with email that is in use email', (done) => {
fakeUsersService.find = () =>
Promise.resolve([{ id: 1, email: 'a', password: '1' } as User]);
service.signup('asdf@asdf.com', 'asdf').catch(() => done());
});
同様の感じでサインインもテスト。
一つ目はfindのデフォルトが何も返ってこないように設定しているのでそのまま書ける。
it('既存ユーザがいる場合サインイン失敗', (done) => {
service.signin('asdf@asdf.com', 'asdf').catch(() => {
done();
});
});
it('パスワードが違う場合にサインイン失敗', (done) => {
fakeUsersService.find = () =>
Promise.resolve([{ email: 'asdf@asdf.com', password: 'asdf' } as User]);
service.signin('wase@wase.com', 'wase').catch(() => done());
});
it('パスワードが正しい場合にサインイン成功', async () => {
// 一度ハッシュ化したパスワードを作成して確認しておく
// const user = await service.signup('asdf@asdf.com', 'asdf');
// console.log(user);
fakeUsersService.find = () =>
Promise.resolve([
{
email: 'asdf@asdf.com',
password:
'423caf1795c6a034.a7b5f4027d502936775218034f3e884941a58336fc139b14f0e54a84e135f1c6',
} as User,
]);
const user = await service.signin('asdf@asdf.com', 'asdf');
expect(user).toBeDefined();
});
これくらいの規模だとまあ大丈夫だけどさらに複雑になったらモックも難しくなる。
より簡略化したcreate, findを定義して使うように修正する。
.
.
beforeEach(async () => {
// ユーザが格納される擬似ストレージ
const users: User[] = [];
fakeUsersService = {
find: (email: string) => {
const filteredUsers = users.filter((user) => user.email === email);
return Promise.resolve(filteredUsers);
},
create: (email: string, password: string) => {
const user = {
id: Math.floor(Math.random() * 999999),
email,
password,
} as User;
users.push(user);
return Promise.resolve(user);
},
};
.
.
テストが簡略化できる
// before
it('パスワードが正しい場合にサインイン成功', async () => {
// 一度ハッシュ化したパスワードを作成して確認しておく
// const user = await service.signup('asdf@asdf.com', 'asdf');
// console.log(user);
fakeUsersService.find = () =>
Promise.resolve([
{
email: 'asdf@asdf.com',
password:
'423caf1795c6a034.a7b5f4027d502936775218034f3e884941a58336fc139b14f0e54a84e135f1c6',
} as User,
]);
const user = await service.signin('asdf@asdf.com', 'asdf');
expect(user).toBeDefined();
});
// after
it('パスワードが正しい場合にサインイン成功', async () => {
await service.signup('asdf@asdf.com', 'asdf');
const user = await service.signin('asdf@asdf.com', 'asdf');
expect(user).toBeDefined();
});
他のテストも以下のようにより自然なテストに修正できる。
// before
it('throws an error if user signs up with email that is in use email', (done) => {
fakeUsersService.find = () =>
Promise.resolve([{ id: 1, email: 'a', password: '1' } as User]);
service.signup('asdf@asdf.com', 'asdf').catch(() => done());
});
// after
it('throws an error if user signs up with email that is in use email', (done) => {
service.signup('asdf@asdf.com', 'asdf').then(() => {
service.signup('asdf@asdf.com', 'asdf').catch(() => done());
});
});
it('パスワードが違う場合にサインイン失敗', (done) => {
fakeUsersService.find = () =>
Promise.resolve([{ email: 'asdf@asdf.com', password: 'asdf' } as User]);
service.signin('wase@wase.com', 'wase').catch(() => done());
});
// after
it('パスワードが違う場合にサインイン失敗', (done) => {
service.signup('wase@wase.com', 'wase').then(() => {
service.signin('wase@wase.com', 'wasee').catch(() => done());
});
});
コントローラのテスト
雛形は自動生成される。
コントローラーのconstructorを見て、関係するサービスをモックする。
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
let controller: UsersController;
let fakeUsersService: Partial<UsersService>;
let fakeAuthService: Partial<AuthService>;
beforeEach(async () => {
fakeUsersService = {
findOne: () => {},
find: () => {},
remove: () => {},
update: () => {},
};
fakeAuthService = {
signup: () => {},
signin: () => {},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
}).compile();
controller = module.get<UsersController>(UsersController);
});
.
.
それぞれの処理はvscodeでホバーするとtypeエラーの内容が出てくるので従って、ダミーの処理を書いていく。
e2eテストの例
雛形は自動生成されているのでコピペして調整する
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('Authentication System (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('handles a signup request', () => {
const email = 'asdfdafad@test.com';
return request(app.getHttpServer())
.post('/auth/signup')
.send({ email, password: 'dafadf' })
.expect(201)
.then((res) => {
const { id, email } = res.body;
expect(id).toBeDefined();
expect(email).toEqual(email);
});
});
});
普通にテスト実施するとエラーが起きる。
[Nest] 91194 - 2021/11/28 20:46:13 ERROR [ExceptionsHandler] Cannot set property 'userId' of undefined
TypeError: Cannot set property 'userId' of undefined
ミドルウェアが不足していることが原因。
別途切り分けてテスト側でも呼び出せるようにする。
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
// Nestと相性が悪い?ためrequireで使用する
const cookieSession = require('cookie-session');
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// app.use~の部分
app.use(
cookieSession({
keys: ['asdfasdf'],
}),
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
await app.listen(3000);
}
bootstrap();
import { INestApplication, ValidationPipe } from '@nestjs/common';
// Nestと相性が悪い?ためrequireで使用する
const cookieSession = require('cookie-session');
export const setupApp = (app: INestApplication) => {
// ミドルウェアをここで追加
app.use(
cookieSession({
keys: ['asdfasdf'],
}),
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
};
んで、使う。
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { setupApp } from './setup-app';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
setupApp(app);
await app.listen(3000);
}
bootstrap();
テスト側でも呼ぶようにする。
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { setupApp } from '../src/setup-app';
describe('Authentication System (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// ここ
setupApp(app);
await app.init();
});
it('handles a signup request', () => {
const email = 'asdfdafad@test.com';
return request(app.getHttpServer())
.post('/auth/signup')
.send({ email, password: 'dafadf' })
.expect(201)
.then((res) => {
const { id, email } = res.body;
expect(id).toBeDefined();
expect(email).toEqual(email);
});
});
});
メールアドレスを変えてテストを実行するとパスする。
もう一つの方法。
app.module.tsにミドルウェアを含ませる。
import { MiddlewareConsumer, Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
import { User } from './users/user.entity';
import { Report } from './reports/report.entity';
const cookieSession = require('cookie-session');
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'db.sqlite',
entities: [User, Report],
synchronize: true,
}),
UsersModule,
ReportsModule,
],
controllers: [AppController],
providers: [
AppService,
// ここでバリデーションパイプ
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
}),
},
],
})
export class AppModule {
// ここでクッキーセッション
configure(consumer: MiddlewareConsumer) {
consumer
.apply(
cookieSession({
keys: ['asdfasdf'],
}),
)
.forRoutes('*');
}
}
NestJSで環境変数を扱う
$ npm i @nestjs/config
設定
import { MiddlewareConsumer, Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_PIPE } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
import { User } from './users/user.entity';
import { Report } from './reports/report.entity';
const cookieSession = require('cookie-session');
@Module({
imports: [
// ここ
ConfigModule.forRoot({
// グローバルにする設定
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV}`,
}),
// 宣言後、database名などに環境変数を使いたいがここでprocess.env.DB_NAMEなどとしてもundefinedになる
// TypeOrmModule.forRoot({
// type: 'sqlite',
// database: 'db.sqlite',
// entities: [User, Report],
// synchronize: true,
// }),
// TypeOrmの設定を以下のように変える
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return {
type: 'sqlite',
database: config.get<string>('DB_NAME'),
synchronize: true,
entities: [User, Report],
};
},
}),
.
.
このままだとNODE_ENVの値はundefinedになる。
cross-envを利用する。
$ npm install cross-env
$ npm
各scriptsを書き換えるとNODE_ENVに環境の値が入るようになる。
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "cross-env NODE_ENV=development nest start",
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "cross-env NODE_ENV=test jest",
"test:watch": "cross-env NODE_ENV=test jest --watch --maxWorkers=1",
"test:cov": "cross-env NODE_ENV=test jest --coverage",
"test:debug": "cross-env NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json"
},
環境変数でテスト用のDBを用意できるようになったので、e2eテストのたびにDBをワイプして、何度も登録に関するテストが通るようにしたい。現状、同じメールアドレスで登録テストを実施してエラーになってしまう。
設定ファイルにsetupファイルに関する記述を追加する。
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
// ここ エラーが出る場合はrelativeパスに変更
"setupFilesAfterEnv": ["<rootDir>/setup.ts>"]
}
このrootDirはtestディレクトリを示す。
setup.tsを作成
import { rm } from 'fs/promises';
import { join } from 'path';
import { getConnection } from 'typeorm';
// sqliteファイルを削除
global.beforeEach(async () => {
try {
await rm(join(__dirname, '..', 'test.sqlite'));
} catch (err) {}
});
// テストが終わったら接続を切る。切らないと削除できない。
global.afterEach(async () => {
const conn = await getConnection();
await conn.close();
});
テスト追加
it('サインアップしてユーザ情報取得', async () => {
const email = 'first@test.com';
const res = await request(app.getHttpServer())
.post('/auth/signup')
.send({ email, password: 'dafadf' })
.expect(201);
const cookie = res.get('Set-Cookie');
const { body } = await request(app.getHttpServer())
.get('/auth/whoami')
.set('Cookie', cookie)
.expect(200);
expect(body.email).toEqual(email);
});