Когда мы думаем о разработке бэкенд-сервисов REST API, NodeJS — чуть ли не первое, что приходит на ум. Как вы, должно быть, знаете, NodeJS — это среда выполнения JavaScript, которая позволяет выполнять код JavaScript в небраузерном окружении. Для создания сетевых сервисов мы обычно используем такие фреймворки, как Express и NestJS.

NestJS — это особая философия проектирования, которая помогает разработать хорошую структуру проекта. Вот основные части этого фреймворка.

  • Контроллер (Controller): определяет конечные точки API и их обработку.
  • Сервис (Service): дополнительный паттерн, который отделяет контроллер от бизнес-логики. Класс Service использует другие компоненты, такие как Model, Cache и прочие сервисы, чтобы помочь контроллерам в обработке запроса.
  • Модуль (Module): помогает разрешать зависимости для контроллеров, сервисов и других компонентов путем импорта и экспорта экземпляров. Другие модули обращаются к экспортированным компонентам для разрешения своих зависимостей.
  • APP_GUARD: применяется ко всем маршрутам контроллера глобально. Порядок его выполнения зависит от позиции в списке провайдеров. Когда запрос направляется к контроллеру, он сначала проходит через классы Guard (у нас есть возможность применить Guard к конкретному контроллеру или методу контроллера). Классы Guard обычно используются для аутентификации и авторизации.
  • APP_PIPE: применяет глобальные каналы для преобразования и проверки всех входящих запросов, прежде чем они достигнут обработчика маршрутов контроллера.
  • APP_INTERCEPTOR: используется для преобразования данных перед их отправкой клиенту. Он может применяться для реализации общих форматов ответов API или валидации ответов.
  • APP_FILTER: используется для определения центральной обработки исключений и отправки ответов на ошибки в общем формате.
  • Аннотации: одной из главных особенностей фреймворка NestJS является активное использование аннотаций. Их применяют для получения экземпляров через модули, определения правил валидации и т. д.

Вы увидите, как реализовать все вышеперечисленное в проекте wimm-node-app. Но прежде обсудим, как мы определяем функцию. Надежной практикой всегда является инкапсуляция функций, т.е. хранение всего, что связано с функцией, в одном каталоге функций. Функция означает общий базовый url маршрута. Так, /blog и /content — это две разные функции. В общем случае функция имеет следующую структуру.

  • dto: представляет собой тело запроса и ответа. Мы применяем необходимые валидации к dto.
  • schema: содержит модель коллекций mongo (если используется mongo, иначе — любые другие ORM-модели).
  • controller: определяет функции обработчика маршрутов и многое другое.
  • service: помогает контроллеру с бизнес-логикой.

Примечание: чтобы продолжить работу над этой статьей, следует клонировать GitHub-репозиторий wimm-node-app.

Посмотрим на пример с mentor из проекта:

mentor
├── dto
│ ├── create-mentor.dto.ts
│ ├── mentor-info.dto.ts
│ └── update-mentor.dto.ts
├── schemas
│ └── mentor.schema.ts
├── mentor-admin.controller.ts
├── mentor.controller.ts
├── mentor.module.ts
├── mentor.service.ts
└── mentors.controller.ts

create-mentor.dto.ts:

import {
IsOptional,
IsUrl,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';

export class CreateMentorDto {
@MinLength(3)
@MaxLength(50)
readonly name: string;

@MinLength(3)
@MaxLength(50)
readonly occupation: string;

@MinLength(3)
@MaxLength(300)
readonly title: string;

@MinLength(3)
@MaxLength(10000)
readonly description: string;

@IsUrl({ require_tld: false })
@MaxLength(300)
readonly thumbnail: string;

@IsUrl({ require_tld: false })
@MaxLength(300)
readonly coverImgUrl: string;

@IsOptional()
@Min(0)
@Max(1)
readonly score: number;

constructor(params: CreateMentorDto) {
Object.assign(this, params);
}
}

mentor.schema.ts:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose, { HydratedDocument, Types } from 'mongoose';
import { User } from '../../user/schemas/user.schema';

export type MentorDocument = HydratedDocument<Mentor>;

@Schema({ collection: 'mentors', versionKey: false, timestamps: true })
export class Mentor {
readonly _id: Types.ObjectId;

@Prop({ required: true, maxlength: 50, trim: true })
name: string;

@Prop({ required: true, maxlength: 300, trim: true })
title: string;

@Prop({ required: true, maxlength: 300, trim: true })
thumbnail: string;

@Prop({ required: true, maxlength: 50, trim: true })
occupation: string;

@Prop({ required: true, maxlength: 10000, trim: true })
description: string;

@Prop({ required: true, maxlength: 300, trim: true })
coverImgUrl: string;

@Prop({ default: 0.01, max: 1, min: 0 })
score: number;

@Prop({
type: mongoose.Schema.Types.ObjectId,
ref: User.name,
required: true,
})
createdBy: User;

@Prop({
type: mongoose.Schema.Types.ObjectId,
ref: User.name,
required: true,
})
updatedBy: User;

@Prop({ default: true })
status: boolean;
}

export const MentorSchema = SchemaFactory.createForClass(Mentor);

MentorSchema.index(
{ name: 'text', occupation: 'text', title: 'text' },
{ weights: { name: 5, occupation: 1, title: 2 }, background: false },
);

MentorSchema.index({ _id: 1, status: 1 });

mentor.controller.ts:

import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Types } from 'mongoose';
import { MongoIdTransformer } from '../common/mongoid.transformer';
import { MentorService } from './mentor.service';
import { MentorInfoDto } from './dto/mentor-info.dto';

@Controller('mentor')
export class MentorController {
constructor(private readonly mentorService: MentorService) {}

@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
): Promise<MentorInfoDto> {
const mentor = await this.mentorService.findById(id);
if (!mentor) throw new NotFoundException('Mentor Not Found');
return new MentorInfoDto(mentor);
}
}

mentor.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { Mentor } from './schemas/mentor.schema';
import { User } from '../user/schemas/user.schema';
import { CreateMentorDto } from './dto/create-mentor.dto';
import { UpdateMentorDto } from './dto/update-mentor.dto';
import { PaginationDto } from '../common/pagination.dto';

@Injectable()
export class MentorService {
constructor(
@InjectModel(Mentor.name) private readonly mentorModel: Model<Mentor>,
) {}

INFO_PARAMETERS = '-description -status';

async create(admin: User, createMentorDto: CreateMentorDto): Promise<Mentor> {
const created = await this.mentorModel.create({
...createMentorDto,
createdBy: admin,
updatedBy: admin,
});
return created.toObject();
}

async findById(id: Types.ObjectId): Promise<Mentor | null> {
return this.mentorModel.findOne({ _id: id, status: true }).lean().exec();
}

async search(query: string, limit: number): Promise<Mentor[]> {
return this.mentorModel
.find({
$text: { $search: query, $caseSensitive: false },
status: true,
})
.select(this.INFO_PARAMETERS)
.limit(limit)
.lean()
.exec();
}

...
}

Теперь посмотрим на структуру проекта:

  • src: исходный код приложения;
  • test: интеграционные тесты e2e;
  • disk: подмодуль, серверное файловое хранилище (только для демонстрационных целей);
  • keys: RSA-ключи для JWT-токена;
  • остальное — конфигурационные файлы для сборки проекта.

Разберем подробно каталог src.

  1. config: определяем переменные среды в файле .env и загружаем их как конфиги.

database.config.ts:

import { registerAs } from '@nestjs/config';

export const DatabaseConfigName = 'database';

export interface DatabaseConfig {
name: string;
host: string;
port: number;
user: string;
password: string;
minPoolSize: number;
maxPoolSize: number;
}

export default registerAs(DatabaseConfigName, () => ({
name: process.env.DB_NAME || '',
host: process.env.DB_HOST || '',
port: process.env.DB_PORT || '',
user: process.env.DB_USER || '',
password: process.env.DB_USER_PWD || '',
minPoolSize: parseInt(process.env.DB_MIN_POOL_SIZE || '5'),
maxPoolSize: parseInt(process.env.DB_MAX_POOL_SIZE || '10'),
}));
  1. setup: определяет подключение к базе данных и пользовательский логгер Winston.

src/setup/database.factory.ts:

import { Injectable, Logger } from '@nestjs/common';
import {
MongooseOptionsFactory,
MongooseModuleOptions,
} from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { DatabaseConfig, DatabaseConfigName } from '../config/database.config';
import mongoose from 'mongoose';
import { ServerConfig, ServerConfigName } from '../config/server.config';

@Injectable()
export class DatabaseFactory implements MongooseOptionsFactory {
constructor(private readonly configService: ConfigService) {}

createMongooseOptions(): MongooseModuleOptions {
const dbConfig =
this.configService.getOrThrow<DatabaseConfig>(DatabaseConfigName);

const { user, host, port, name, minPoolSize, maxPoolSize } = dbConfig;

const password = encodeURIComponent(dbConfig.password);

const uri = `mongodb://${user}:${password}@${host}:${port}/${name}`;

const serverConfig =
this.configService.getOrThrow<ServerConfig>(ServerConfigName);
if (serverConfig.nodeEnv == 'development') mongoose.set({ debug: true });

Logger.debug('Database URI:' + uri);

return {
uri: uri,
autoIndex: true,
minPoolSize: minPoolSize,
maxPoolSize: maxPoolSize,
connectTimeoutMS: 60000, // Give up initial connection after 10 seconds
socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity,
};
}
}
  1. app.module.ts: загружает все остальные модули и конфигурации для приложения.
@Module({
imports: [
ConfigModule.forRoot({
load: [
serverConfig,
databaseConfig,
cacheConfig,
authkeyConfig,
tokenConfig,
diskConfig,
],
cache: true,
envFilePath: getEnvFilePath(),
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
useClass: DatabaseFactory,
}),
RedisCacheModule,
CoreModule,
AuthModule,
MessageModule,
FilesModule,
ScrapperModule,
MentorModule,
TopicModule,
SubscriptionModule,
ContentModule,
BookmarkModule,
SearchModule,
],
providers: [
{
provide: 'Logger',
useClass: WinstonLogger,
},
],
})
export class AppModule {}

function getEnvFilePath() {
return process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
}
  1. main.ts: первый скрипт, который выполняется при запуске сервера. Он создает приложение Nest, загружая AppModule:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { ServerConfig, ServerConfigName } from './config/server.config';

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

const configService = app.get(ConfigService);
const serverConfig = configService.getOrThrow<ServerConfig>(ServerConfigName);

await app.listen(serverConfig.port);
}

server();

Теперь можем перейти к более подробному изучению архитектуры. Прежде всего важно понять модуль core. Он содержит строительный блок для архитектуры.

Чтобы сделать сервис согласованным, нужно определить структуру запроса и ответа. REST API будут отправлять 2 типа ответов:

// 1. Реакция на сообщение
{
"statusCode": 10000,
"message": "something",
}

// 2. Ответ данных
{
"statusCode": 10000,
"message": "something",
"data": {DTO}
}

Создадим классы для представления этой структуры — src/core/http/response.ts:

export enum StatusCode {
SUCCESS = 10000,
FAILURE = 10001,
RETRY = 10002,
INVALID_ACCESS_TOKEN = 10003,
}

export class MessageResponse {
readonly statusCode: StatusCode;
readonly message: string;

constructor(statusCode: StatusCode, message: string) {
this.statusCode = statusCode;
this.message = message;
}
}

export class DataResponse<T> extends MessageResponse {
readonly data: T;

constructor(statusCode: StatusCode, message: string, data: T) {
super(statusCode, message);
this.data = data;
}
}

Теперь у нас есть 3 типа запросов — публичный (Public), приватный (Private) и защищенный (Protected). Мы определяем их внутри src/core/http/request.ts:

import { Request } from 'express';
import { User } from '../../user/schemas/user.schema';
import { ApiKey } from '../../auth/schemas/apikey.schema';
import { Keystore } from '../../auth/schemas/keystore.schema';

export interface PublicRequest extends Request {
apiKey: ApiKey;
}

export interface RoleRequest extends PublicRequest {
currentRoleCodes: string[];
}

export interface ProtectedRequest extends RoleRequest {
user: User;
accessToken: string;
keystore: Keystore;
}

Помимо этого, когда DTO возвращается из контроллера, нужно выполнить два действия.

  1. Валидация ответа (src/core/interceptors/response.validations.ts):
// response-validation.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
InternalServerErrorException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ValidationError, validateSync } from 'class-validator';

@Injectable()
export class ResponseValidation implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
if (data instanceof Object) {
const errors = validateSync(data);
if (errors.length > 0) {
const messages = this.extractErrorMessages(errors);
throw new InternalServerErrorException([
'Response validation failed',
...messages,
]);
}
}
return data;
}),
);
}

private extractErrorMessages(
errors: ValidationError[],
messages: string[] = [],
): string[] {
for (const error of errors) {
if (error) {
if (error.children && error.children.length > 0)
this.extractErrorMessages(error.children, messages);
const constraints = error.constraints;
if (constraints) messages.push(Object.values(constraints).join(', '));
}
}
return messages;
}
}
  1. Трансформация ответа: преобразование DTO в объект response (src/core/interceptors/response.transformer.ts):
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { DataResponse, MessageResponse, StatusCode } from '../http/response';

@Injectable()
export class ResponseTransformer implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
if (data instanceof MessageResponse) return data;
if (data instanceof DataResponse) return data;
if (typeof data == 'string')
return new MessageResponse(StatusCode.SUCCESS, data);
return new DataResponse(StatusCode.SUCCESS, 'success', data);
}),
);
}
}

Наконец, мы также должны определить фильтр обработки исключений в src/core/interceptors/exception.handler.ts:

// exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { TokenExpiredError } from '@nestjs/jwt';
import { Request, Response } from 'express';
import { StatusCode } from '../http/response';
import { isArray } from 'class-validator';
import { ConfigService } from '@nestjs/config';
import { ServerConfig, ServerConfigName } from '../../config/server.config';
import { WinstonLogger } from '../../setup/winston.logger';

@Catch()
export class ExpectionHandler implements ExceptionFilter {
constructor(
private readonly configService: ConfigService,
private readonly logger: WinstonLogger,
) {}

catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let statusCode = StatusCode.FAILURE;
let message: string = 'Something went wrong';
let errors: any[] | undefined = undefined;

if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
if (typeof body === 'string') {
message = body;
} else if ('message' in body) {
if (typeof body.message === 'string') {
message = body.message;
} else if (isArray(body.message) && body.message.length > 0) {
message = body.message[0];
errors = body.message;
}
}
if (exception instanceof InternalServerErrorException) {
this.logger.error(exception.message, exception.stack);
}

if (exception instanceof UnauthorizedException) {
if (message.toLowerCase().includes('invalid access token')) {
statusCode = StatusCode.INVALID_ACCESS_TOKEN;
response.appendHeader('instruction', 'logout');
}
}
} else if (exception instanceof TokenExpiredError) {
status = HttpStatus.UNAUTHORIZED;
statusCode = StatusCode.INVALID_ACCESS_TOKEN;
response.appendHeader('instruction', 'refresh_token');
message = 'Token Expired';
} else {
const serverConfig =
this.configService.getOrThrow<ServerConfig>(ServerConfigName);
if (serverConfig.nodeEnv === 'development') message = exception.message;
this.logger.error(exception.message, exception.stack);
}

response.status(status).json({
statusCode: statusCode,
message: message,
errors: errors,
url: request.url,
});
}
}

Для практического применения создадим модуль CoreModule. Затем CoreModule добавляется в AppModule:

import { Module, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ResponseTransformer } from './interceptors/response.transformer';
import { ExpectionHandler } from './interceptors/exception.handler';
import { ResponseValidation } from './interceptors/response.validations';
import { ConfigModule } from '@nestjs/config';
import { WinstonLogger } from '../setup/winston.logger';
import { CoreController } from './core.controller';

@Module({
imports: [ConfigModule],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ResponseTransformer },
{ provide: APP_INTERCEPTOR, useClass: ResponseValidation },
{ provide: APP_FILTER, useClass: ExpectionHandler },
{
provide: APP_PIPE,
useValue: new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
},
WinstonLogger,
],
controllers: [CoreController],
})
export class CoreModule {}

Следующая важная функция — auth, которая предоставляет ApiKeyGuard, AuthGuard (аутентификация) и RolesGuard (авторизация).

src/auth/guards/apikey.guard.ts проверяет заголовок x-api-key и его разрешения:

import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { HeaderName } from '../../core/http/header';
import { Reflector } from '@nestjs/core';
import { Permissions } from '../decorators/permissions.decorator';
import { PublicRequest } from '../../core/http/request';
import { Permission } from '../../auth/schemas/apikey.schema';
import { AuthService } from '../auth.service';

@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const permissions = this.reflector.get(Permissions, context.getClass()) ?? [
Permission.GENERAL,
];
if (!permissions) throw new ForbiddenException();

const request = context.switchToHttp().getRequest<PublicRequest>();

const key = request.headers[HeaderName.API_KEY]?.toString();
if (!key) throw new ForbiddenException();

const apiKey = await this.authService.findApiKey(key);
if (!apiKey) throw new ForbiddenException();

request.apiKey = apiKey;

for (const askedPermission of permissions) {
for (const allowedPemission of apiKey.permissions) {
if (allowedPemission === askedPermission) return true;
}
}

throw new ForbiddenException();
}
}

src/auth/guards/auth.guard.ts: проверяет заголовок аутентификации JWT. Он также добавляет user и keystore в объект запроса для получения другими обработчиками:

import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ProtectedRequest } from '../../core/http/request';
import { Types } from 'mongoose';
import { AuthService } from '../auth.service';
import { UserService } from '../../user/user.service';

@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
private readonly userService: UserService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;

const request = context.switchToHttp().getRequest<ProtectedRequest>();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException();

const payload = await this.authService.verifyToken(token);
const valid = this.authService.validatePayload(payload);
if (!valid) throw new UnauthorizedException('Invalid Access Token');

const user = await this.userService.findUserById(
new Types.ObjectId(payload.sub),
);
if (!user) throw new UnauthorizedException('User not registered');

const keystore = await this.authService.findKeystore(user, payload.prm);
if (!keystore) throw new UnauthorizedException('Invalid Access Token');

request.user = user;
request.keystore = keystore;

return true;
}

private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

src/auth/guards/roles.guard.ts проверяет роли пользователей для конкретного контроллера или обработчика контроллера.

Чтобы указать роли внутри контроллера, определяем декоратор в src/auth/decorators/role.decorator.ts:

import { Reflector } from '@nestjs/core';
import { RoleCode } from '../schemas/role.schema';

export const Roles = Reflector.createDecorator<RoleCode[]>();

Применяем этот декоратор к контроллеру. Пример: src/mentor/mentor-admin.controller.ts.

@Roles([RoleCode.ADMIN])
@Controller('mentor/admin')
export class MentorAdminController {
...
}

Наконец, src/auth/guards/roles.guard.ts:

import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from '../decorators/roles.decorator';
import { ProtectedRequest } from '../../core/http/request';

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
let roles = this.reflector.get(Roles, context.getHandler());
if (!roles) roles = this.reflector.get(Roles, context.getClass());
if (roles) {
const request = context.switchToHttp().getRequest<ProtectedRequest>();
const user = request.user;
if (!user) throw new ForbiddenException('Permission Denied');

const hasRole = () =>
user.roles.some((role) => !!roles.find((item) => item === role.code));

if (!hasRole()) throw new ForbiddenException('Permission Denied');
}

return true;
}
}

Теперь мы видим полную картину на диаграмме. На ней показан путь запроса по архитектуре, в результате которого мы получаем ответ.

В архитектуру также добавляется пара продуктивных инструментов. Пример — валидация и преобразование строки параметра id в объект MongoId. Посмотрим, как обрабатывается параметр id mongo с помощью MongoIdTransformer.

import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Types } from 'mongoose';
import { MongoIdTransformer } from '../common/mongoid.transformer';
import { MentorService } from './mentor.service';
import { MentorInfoDto } from './dto/mentor-info.dto';

@Controller('mentor')
export class MentorController {
constructor(private readonly mentorService: MentorService) {}

@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
): Promise<MentorInfoDto> {
const mentor = await this.mentorService.findById(id);
if (!mentor) throw new NotFoundException('Mentor Not Found');
return new MentorInfoDto(mentor);
}
}

MongoIdTransformer реализуется в src/common/mongoid.transformer.ts:

import {
PipeTransform,
Injectable,
BadRequestException,
ArgumentMetadata,
} from '@nestjs/common';
import { Types } from 'mongoose';

@Injectable()
export class MongoIdTransformer implements PipeTransform<any> {
transform(value: any, metadata: ArgumentMetadata): any {
if (typeof value !== 'string') return value;

if (metadata.metatype?.name === 'ObjectId') {
if (!Types.ObjectId.isValid(value)) {
const key = metadata?.data ?? '';
throw new BadRequestException(`${key} must be a mongodb id`);
}
return new Types.ObjectId(value);
}

return value;
}
}

Аналогичным образом определяем валидацию IsMongoIdObject для использования в DTO.

export class ContentInfoDto {
@IsMongoIdObject()
_id: Types.ObjectId;

...
}

IsMongoIdObject реализуется в src/common/mongo.validation.ts:

import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
import { Types } from 'mongoose';

export function IsMongoIdObject(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsMongoIdObject',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: {
validate(value: any) {
return Types.ObjectId.isValid(value);
},

defaultMessage(validationArguments?: ValidationArguments) {
const property = validationArguments?.property ?? '';
return `${property} should be a valid MongoId`;
},
},
});
};
}

В этой архитектуре есть еще много нюансов, выполняющих важные функции. Вы можете открыть их, изучая код.

Кэширование — важный инструмент для работы с веб-сервером. В этом проекте используется Redis для кэша in-memory. 

Обертку Redis можно найти в src/cache/redis-cache.ts, который реализует кэш-менеджер Nest. Таким образом, обеспечивается пользовательский CacheInterceptor через API кэша Nest. Я не буду обсуждать здесь этот код, поскольку он является внутренней реализацией и не должен быть изменен.

Затем создаем Factory, сервис и модуль для включения кэша Redis для приложения.

src/cache/cache.factory.ts:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CacheConfig, CacheConfigName } from '../config/cache.config';
import { redisStore } from './redis-cache';
import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager';

@Injectable()
export class CacheConfigFactory implements CacheOptionsFactory {
constructor(private readonly configService: ConfigService) {}

async createCacheOptions(): Promise<CacheModuleOptions> {
const cacheConfig =
this.configService.getOrThrow<CacheConfig>(CacheConfigName);
const redisURL = `redis://:${cacheConfig.password}@${cacheConfig.host}:${cacheConfig.port}`;
return {
store: redisStore,
url: redisURL,
ttl: cacheConfig.ttl,
};
}
}

src/cache/cache.service.ts:

import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { RedisStore } from './redis-cache';

@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}

async getValue(key: string): Promise<string | null | undefined> {
return await this.cache.get(key);
}

async setValue(key: string, value: string): Promise<void> {
await this.cache.set(key, value);
}

async delete(key: string): Promise<void> {
await this.cache.del(key);
}

onModuleDestroy() {
(this.cache.store as RedisStore).client.disconnect();
}
}

src/cache/redis-cache.module.ts:

import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule } from '@nestjs/config';
import { CacheConfigFactory } from './cache.factory';
import { CacheService } from './cache.service';

@Module({
imports: [
ConfigModule,
CacheModule.registerAsync({
imports: [ConfigModule],
useClass: CacheConfigFactory,
}),
],
providers: [CacheService],
exports: [CacheService, CacheModule],
})
export class RedisCacheModule {}

Теперь мы можем использовать его внутри любого контроллера для кэширования запросов с помощью CacheInterceptor:

import { CacheInterceptor } from '@nestjs/cache-manager';
...

@Controller('content')
export class ContentController {
constructor(private readonly contentService: ContentService) {}

@UseInterceptors(CacheInterceptor)
@Get('id/:id')
async findOne(
@Param('id', MongoIdTransformer) id: Types.ObjectId,
@Request() request: ProtectedRequest,
): Promise<ContentInfoDto> {
return await this.contentService.findOne(id, request.user);
}

...

}

Тестирование — неотъемлемая часть любого качественного проекта. В проекте широко реализованы как модульные, так и интеграционные тесты. Покрытие кода составляет более 75 %.

Об эффективных модульных и интеграционных тестах я напишу в отдельной статье. А пока можете изучить проведение модульных тестов с именем файла {feature}.spect.ts, например src/auth/auth.guard.spec.ts. Интеграционные тесты находятся внутри каталога test, пример — app-auth.e2e-spec.ts.

Для интеграционных тестов подключаемся к тестовой базе данных. Конфигурация для тестов берется из файла .env.test.

Теперь вы можете подробно изучить репозиторий, и я уверен, что это позволит сэкономить вам много времени.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Janishar Ali: Mastering NestJS — Building an Effective REST API Backend

Предыдущая статья10 однострочников, позволяющих профессионально писать JavaScript-код