Когда мы думаем о разработке бэкенд-сервисов 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.
- 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'),
}));
- 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,
};
}
}
- 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';
}
- 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 возвращается из контроллера, нужно выполнить два действия.
- Валидация ответа (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;
}
}
- Трансформация ответа: преобразование 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.
Теперь вы можете подробно изучить репозиторий, и я уверен, что это позволит сэкономить вам много времени.
Читайте также:
- NestJS и PostgreSQL: руководство по настройке
- Технологический стек для создания веб-приложений
- Почему NestJS — лучший фреймворк Node.js для микросервисов
Читайте нас в Telegram, VK и Дзен
Перевод статьи Janishar Ali: Mastering NestJS — Building an Effective REST API Backend