Custom Decorators: власні декоратори
Custom Decorators: власні декоратори
🎯 Мета лекції
- Зрозуміти різницю між parameter decorators (@CurrentUser) та metadata decorators (@Roles)
- Опанувати
createParamDecorator()для витягування даних з request у параметрах методів - Навчитися створювати metadata decorators через
@SetMetadata()для Guards/Interceptors - Вивчити композицію декораторів через
applyDecorators()для комбінування функціоналу - Засвоїти інтеграцію Pipes у parameter decorators для валідації та трансформації
- Практикувати створення декораторів для JWT payload, query parameters, headers
- Розуміти advanced patterns: умовні декоратори, декоратори з DI, типізовані декоратори
🔑 Ключові терміни
- Parameter Decorator (декоратор параметра): витягує дані з request для параметрів методу (@CurrentUser, @Cookie)
- Metadata Decorator (декоратор метаданих): прикріплює метадані до класів/методів для Guards (@Roles, @Public)
- createParamDecorator (фабрика декораторів): функція для створення parameter decorators з доступом до ExecutionContext
- applyDecorators (композиція): функція для комбінування кількох декораторів в один
- Decorator Composition (композиція декораторів): об'єднання декораторів для скорочення коду
- Decorator Factory (фабрика декораторів): функція, що повертає декоратор з параметрами
Типи кастомних декораторів у NestJS
NestJS підтримує два основні типи кастомних декораторів:
1. Parameter Decorators (декоратори параметрів)
Витягують дані з request для використання у параметрах методів:
// Витягування user з request.user
@Get('profile')
getProfile(@CurrentUser() user: User) {
return user;
}
// Витягування конкретного поля
@Get('email')
getEmail(@CurrentUser('email') email: string) {
return { email };
}
Призначення: спрощення доступу до request.user, request.headers, request.cookies без @Req().
2. Metadata Decorators (декоратори метаданих)
Прикріплюють метадані до класів або методів для використання у Guards/Interceptors:
// Встановлення ролей для Guard
@Get('admin')
@Roles('admin', 'moderator')
getAdminData() {
return { data: 'Admin data' };
}
// Позначення публічного endpoint
@Post('login')
@Public()
login(@Body() dto: LoginDto) {
return { token: 'jwt' };
}
Призначення: передача інформації Guards/Interceptors без зміни сигнатури методів.
createParamDecorator() та працюють з даними request. Metadata decorators використовують @SetMetadata() та прикріплюють інформацію для Guards/Interceptors.Parameter Decorators: createParamDecorator()
Створення базового @CurrentUser декоратора
// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user; // Встановлюється AuthGuard
},
);
Використання:
@Controller('users')
export class UsersController {
@Get('profile')
@UseGuards(JwtAuthGuard) // Встановлює request.user
getProfile(@CurrentUser() user: User) {
return {
id: user.id,
email: user.email,
roles: user.roles,
};
}
@Get('settings')
@UseGuards(JwtAuthGuard)
getSettings(@CurrentUser() user: User) {
return this.settingsService.getByUserId(user.id);
}
}
Переваги порівняно з @Req():
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@Req() req: Request) {
const user = req.user; // Ручне витягування
return user;
}
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: User) {
return user; // Автоматично витягується
}
Витягування конкретного поля з data параметра
// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
// Якщо data вказано, повертаємо конкретне поле
return data ? user?.[data] : user;
},
);
Використання:
@Controller('users')
export class UsersController {
@Get('my-id')
getMyId(@CurrentUser('id') userId: string) {
return { userId }; // Лише ID
}
@Get('my-email')
getMyEmail(@CurrentUser('email') email: string) {
return { email }; // Лише email
}
@Get('my-roles')
getMyRoles(@CurrentUser('roles') roles: string[]) {
return { roles }; // Лише roles
}
@Get('profile')
getProfile(@CurrentUser() user: User) {
return user; // Весь об'єкт user
}
}
Декоратор @Cookie для витягування cookies
// decorators/cookie.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Cookie = createParamDecorator(
(cookieName: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return cookieName ? request.cookies?.[cookieName] : request.cookies;
},
);
Використання:
@Controller('auth')
export class AuthController {
@Get('session')
getSession(@Cookie('sessionId') sessionId: string) {
return { sessionId };
}
@Get('all-cookies')
getAllCookies(@Cookie() cookies: Record<string, string>) {
return cookies; // Всі cookies
}
@Post('logout')
logout(@Cookie('refreshToken') refreshToken: string) {
return this.authService.revokeToken(refreshToken);
}
}
Декоратор @IpAddress для витягування IP
// decorators/ip-address.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const IpAddress = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.ip || request.connection.remoteAddress;
},
);
Використання:
@Controller('security')
export class SecurityController {
@Post('login')
async login(@Body() dto: LoginDto, @IpAddress() ip: string) {
// Логування спроби входу з IP
await this.securityService.logLoginAttempt(dto.email, ip);
return this.authService.login(dto);
}
@Get('location')
getLocation(@IpAddress() ip: string) {
return this.geoService.getLocationByIp(ip);
}
}
Декоратор @Headers для витягування заголовків
// decorators/headers.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Headers = createParamDecorator(
(headerName: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return headerName ? request.headers[headerName.toLowerCase()] : request.headers;
},
);
Використання:
@Controller('api')
export class ApiController {
@Get('data')
getData(
@Headers('user-agent') userAgent: string,
@Headers('x-api-key') apiKey: string,
) {
this.logger.log(`Request from: ${userAgent}, API Key: ${apiKey}`);
return { data: [] };
}
@Get('all-headers')
getAllHeaders(@Headers() headers: Record<string, string>) {
return headers;
}
}
Декоратор @QueryParam з валідацією
// decorators/query-param.decorator.ts
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
export const QueryParam = createParamDecorator(
(paramName: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const value = request.query[paramName];
if (!value) {
throw new BadRequestException(`Missing required query parameter: ${paramName}`);
}
return value;
},
);
Використання:
@Controller('products')
export class ProductsController {
@Get()
findAll(
@QueryParam('category') category: string, // Обов'язковий
@Query('page') page?: string, // Опціональний
) {
return this.productsService.findByCategory(category, page);
}
}
Інтеграція Pipes у Parameter Decorators
Parameter decorators можуть використовувати Pipes для валідації та трансформації:
Декоратор з вбудованим ParseIntPipe
@Get(':id')
getOne(@CurrentUser('id') userId: string, @Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id, userId);
}
Кастомний Pipe у декораторі
// pipes/parse-user-id.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseUserIdPipe implements PipeTransform {
transform(value: any) {
if (!value || !value.id) {
throw new BadRequestException('User ID is required');
}
const id = parseInt(value.id, 10);
if (isNaN(id)) {
throw new BadRequestException('User ID must be a number');
}
return id;
}
}
// Використання
@Get('profile')
getProfile(@CurrentUser(ParseUserIdPipe) userId: number) {
return this.service.findById(userId);
}
Декоратор @UserId з автоматичною трансформацією
// decorators/user-id.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const UserId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): number => {
const request = ctx.switchToHttp().getRequest();
const userId = request.user?.id;
// Автоматична трансформація у number
return typeof userId === 'string' ? parseInt(userId, 10) : userId;
},
);
Використання:
@Controller('orders')
export class OrdersController {
@Get('my')
@UseGuards(JwtAuthGuard)
getMyOrders(@UserId() userId: number) {
// userId вже number, не потрібен ParseIntPipe
return this.ordersService.findByUserId(userId);
}
}
Metadata Decorators: @SetMetadata()
Metadata decorators прикріплюють метадані до класів/методів для використання у Guards/Interceptors:
Базовий metadata decorator
// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
Використання з Guard:
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some(role => user.roles?.includes(role));
}
}
// У контролері
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
@Get('users')
@Roles('admin', 'moderator')
getUsers() {
return [];
}
@Delete('users/:id')
@Roles('admin')
deleteUser(@Param('id') id: string) {
return { deleted: true };
}
}
Декоратор @Public для пропуску аутентифікації
// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Використання:
// app.module.ts - глобальний AuthGuard
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
// auth.controller.ts
@Controller('auth')
export class AuthController {
@Post('login')
@Public() // Пропускає JwtAuthGuard
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('register')
@Public()
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Get('profile')
// Без @Public() - потрібна аутентифікація
getProfile(@CurrentUser() user: User) {
return user;
}
}
Декоратор @Permissions для детальної авторизації
// decorators/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
Використання з Guard:
// guards/permissions.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
// User повинен мати ВСІ необхідні permissions
return requiredPermissions.every(permission =>
user.permissions?.includes(permission)
);
}
}
// У контролері
@Controller('posts')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('posts:access') // Метадані на рівні класу
export class PostsController {
@Get()
@RequirePermissions('posts:read') // Метадані на рівні методу
findAll() {
// Потрібні permissions: ['posts:access', 'posts:read']
return [];
}
@Post()
@RequirePermissions('posts:create')
create(@Body() dto: CreatePostDto) {
// Потрібні permissions: ['posts:access', 'posts:create']
return dto;
}
@Delete(':id')
@RequirePermissions('posts:delete')
remove(@Param('id') id: string) {
// Потрібні permissions: ['posts:access', 'posts:delete']
return { deleted: true };
}
}
Декоратор @Timeout для обмеження часу виконання
// decorators/timeout.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const TIMEOUT_KEY = 'timeout';
export const Timeout = (milliseconds: number) => SetMetadata(TIMEOUT_KEY, milliseconds);
Використання з Interceptor:
// interceptors/timeout.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { TIMEOUT_KEY } from '../decorators/timeout.decorator';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const timeoutValue = this.reflector.get<number>(TIMEOUT_KEY, context.getHandler());
if (!timeoutValue) {
return next.handle();
}
return next.handle().pipe(
timeout(timeoutValue),
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException(`Operation timed out after ${timeoutValue}ms`));
}
return throwError(() => err);
}),
);
}
}
// У контролері
@Controller('reports')
@UseInterceptors(TimeoutInterceptor)
export class ReportsController {
@Get('quick')
@Timeout(1000) // 1 секунда
getQuickReport() {
return this.reportsService.getQuick();
}
@Get('detailed')
@Timeout(30000) // 30 секунд
getDetailedReport() {
return this.reportsService.getDetailed();
}
}
Композиція декораторів: applyDecorators()
Комбінування кількох декораторів в один для скорочення коду:
Композитний декоратор @Auth
// decorators/auth.decorator.ts
import { applyDecorators, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from './roles.decorator';
export function Auth(...roles: string[]) {
return applyDecorators(
Roles(...roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized' }),
);
}
Використання:
@Get('admin/users')
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
getUsers() {
return [];
}
@Get('admin/users')
@Auth('admin') // Замість 4 декораторів
getUsers() {
return [];
}
Композитний декоратор @ApiPaginated для Swagger
// decorators/api-paginated.decorator.ts
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
export class PaginatedDto<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export const ApiPaginatedResponse = <TModel extends Type<any>>(model: TModel) => {
return applyDecorators(
ApiExtraModels(PaginatedDto, model),
ApiOkResponse({
description: 'Paginated response',
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
},
},
],
},
}),
);
};
Використання:
@Controller('users')
export class UsersController {
@Get()
@ApiPaginatedResponse(UserDto)
findAll(@Query() paginationDto: PaginationDto): Promise<PaginatedDto<UserDto>> {
return this.usersService.findAll(paginationDto);
}
}
Композитний декоратор @AdminOnly
// decorators/admin-only.decorator.ts
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
export function AdminOnly(description?: string) {
return applyDecorators(
SetMetadata('roles', ['admin']),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiOperation({ summary: description || 'Admin only endpoint' }),
ApiResponse({ status: 403, description: 'Forbidden. Requires admin role.' }),
);
}
Використання:
@Controller('admin')
export class AdminController {
@Get('dashboard')
@AdminOnly('Get admin dashboard statistics')
getDashboard() {
return { stats: {} };
}
@Delete('users/:id')
@AdminOnly('Delete user by ID')
deleteUser(@Param('id') id: string) {
return { deleted: true };
}
}
Композитний декоратор @CachedEndpoint
// decorators/cached-endpoint.decorator.ts
import { applyDecorators, UseInterceptors, SetMetadata } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
import { ApiResponse } from '@nestjs/swagger';
export const CACHE_TTL_KEY = 'cache_ttl';
export function CachedEndpoint(ttl: number = 60, description?: string) {
return applyDecorators(
SetMetadata(CACHE_TTL_KEY, ttl),
UseInterceptors(CacheInterceptor),
ApiResponse({
status: 200,
description: description || `Cached for ${ttl} seconds`
}),
);
}
Використання:
@Controller('products')
export class ProductsController {
@Get()
@CachedEndpoint(300, 'Get all products (cached for 5 minutes)')
findAll() {
return this.productsService.findAll();
}
@Get('featured')
@CachedEndpoint(60, 'Get featured products (cached for 1 minute)')
getFeatured() {
return this.productsService.getFeatured();
}
}
Advanced Patterns: просунуті патерни
Умовний декоратор залежно від environment
// decorators/conditional-guard.decorator.ts
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
export function ConditionalAuth() {
// У development пропускаємо аутентифікацію
if (process.env.NODE_ENV === 'development') {
return applyDecorators();
}
// У production застосовуємо Guards
return applyDecorators(UseGuards(JwtAuthGuard));
}
Використання:
@Controller('debug')
export class DebugController {
@Get('info')
@ConditionalAuth() // Auth лише у production
getDebugInfo() {
return { env: process.env.NODE_ENV };
}
}
Декоратор з Dependency Injection
// decorators/current-tenant.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { TenantService } from '../services/tenant.service';
export const CurrentTenant = createParamDecorator(
async (data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
// Не можна використовувати DI безпосередньо у декораторі!
// Замість цього зберігайте tenantId у request
request.tenantId = tenantId;
return tenantId;
},
);
// Для витягування повного об'єкта Tenant використовуйте Guard або Interceptor
@Injectable()
export class TenantInterceptor implements NestInterceptor {
constructor(private tenantService: TenantService) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
if (tenantId) {
const tenant = await this.tenantService.findById(tenantId);
request.tenant = tenant;
}
return next.handle();
}
}
Використання:
@Controller('api')
@UseInterceptors(TenantInterceptor)
export class ApiController {
@Get('data')
getData(@CurrentTenant() tenantId: string, @Req() req: Request) {
const tenant = req['tenant']; // Повний об'єкт з Interceptor
return this.service.getDataForTenant(tenant);
}
}
Типізований декоратор з generic
// decorators/validated-body.decorator.ts
import { createParamDecorator, ExecutionContext, Type } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { BadRequestException } from '@nestjs/common';
export const ValidatedBody = <T>(type: Type<T>) => {
return createParamDecorator(
async (data: unknown, ctx: ExecutionContext): Promise<T> => {
const request = ctx.switchToHttp().getRequest();
const body = request.body;
// Трансформація та валідація
const instance = plainToInstance(type, body);
const errors = await validate(instance as object);
if (errors.length > 0) {
throw new BadRequestException('Validation failed');
}
return instance;
},
)(data, ctx);
};
Використання:
@Controller('users')
export class UsersController {
@Post()
create(@ValidatedBody(CreateUserDto) dto: CreateUserDto) {
// dto вже валідований
return this.usersService.create(dto);
}
}
Декоратор @Trace для performance tracking
// decorators/trace.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const TRACE_KEY = 'trace';
export interface TraceOptions {
operation: string;
logParams?: boolean;
logResult?: boolean;
}
export const Trace = (options: TraceOptions) => SetMetadata(TRACE_KEY, options);
Interceptor для обробки @Trace:
// interceptors/trace.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { TRACE_KEY, TraceOptions } from '../decorators/trace.decorator';
@Injectable()
export class TraceInterceptor implements NestInterceptor {
private readonly logger = new Logger(TraceInterceptor.name);
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const traceOptions = this.reflector.get<TraceOptions>(TRACE_KEY, context.getHandler());
if (!traceOptions) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const { operation, logParams, logResult } = traceOptions;
const startTime = Date.now();
if (logParams) {
this.logger.log(`[${operation}] Started with params: ${JSON.stringify(request.params)}`);
} else {
this.logger.log(`[${operation}] Started`);
}
return next.handle().pipe(
tap(data => {
const duration = Date.now() - startTime;
if (logResult) {
this.logger.log(`[${operation}] Completed in ${duration}ms - Result: ${JSON.stringify(data)}`);
} else {
this.logger.log(`[${operation}] Completed in ${duration}ms`);
}
}),
);
}
}
Використання:
@Controller('products')
@UseInterceptors(TraceInterceptor)
export class ProductsController {
@Get(':id')
@Trace({ operation: 'GetProduct', logParams: true, logResult: true })
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Post()
@Trace({ operation: 'CreateProduct', logParams: true })
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
}
Практичні приклади: real-world use cases
@RateLimit з IP-based обмеженнями
// decorators/rate-limit.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RATE_LIMIT_KEY = 'rateLimit';
export interface RateLimitOptions {
points: number; // Кількість запитів
duration: number; // Період в секундах
blockDuration?: number; // Час блокування при перевищенні
}
export const RateLimit = (options: RateLimitOptions) =>
SetMetadata(RATE_LIMIT_KEY, options);
Guard для обробки:
// guards/rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RATE_LIMIT_KEY, RateLimitOptions } from '../decorators/rate-limit.decorator';
interface RateLimitRecord {
points: number;
resetTime: number;
blockedUntil?: number;
}
@Injectable()
export class RateLimitGuard implements CanActivate {
private records = new Map<string, RateLimitRecord>();
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const options = this.reflector.get<RateLimitOptions>(
RATE_LIMIT_KEY,
context.getHandler()
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest();
const key = this.generateKey(request);
const now = Date.now();
let record = this.records.get(key);
// Перевірка блокування
if (record?.blockedUntil && now < record.blockedUntil) {
const retryAfter = Math.ceil((record.blockedUntil - now) / 1000);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests',
retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS
);
}
// Скидання або створення запису
if (!record || now > record.resetTime) {
record = {
points: 0,
resetTime: now + options.duration * 1000,
};
this.records.set(key, record);
}
// Перевірка ліміту
if (record.points >= options.points) {
if (options.blockDuration) {
record.blockedUntil = now + options.blockDuration * 1000;
}
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Rate limit exceeded',
retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS
);
}
record.points++;
return true;
}
private generateKey(request: any): string {
const ip = request.ip || request.connection.remoteAddress;
const endpoint = `${request.method}:${request.url}`;
return `${ip}:${endpoint}`;
}
}
Використання:
@Controller('api')
@UseGuards(RateLimitGuard)
export class ApiController {
@Post('upload')
@RateLimit({ points: 5, duration: 3600, blockDuration: 7200 })
// 5 запитів на годину, блокування на 2 години
uploadFile(@Body() dto: UploadDto) {
return this.storageService.upload(dto);
}
@Get('data')
@RateLimit({ points: 100, duration: 60 })
// 100 запитів на хвилину
getData() {
return { data: [] };
}
}
@OwnerOnly для перевірки власника ресурсу
// decorators/owner-only.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const OWNER_ONLY_KEY = 'ownerOnly';
export interface OwnerOnlyOptions {
resourceParam: string; // Назва параметра з ID ресурсу
userIdField?: string; // Поле userId у ресурсі (за замовчуванням 'userId')
}
export const OwnerOnly = (options: OwnerOnlyOptions) =>
SetMetadata(OWNER_ONLY_KEY, options);
Guard для перевірки:
// guards/owner-only.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ModuleRef } from '@nestjs/core';
import { OWNER_ONLY_KEY, OwnerOnlyOptions } from '../decorators/owner-only.decorator';
@Injectable()
export class OwnerOnlyGuard implements CanActivate {
constructor(
private reflector: Reflector,
private moduleRef: ModuleRef,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = this.reflector.get<OwnerOnlyOptions>(
OWNER_ONLY_KEY,
context.getHandler()
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
const resourceId = request.params[options.resourceParam];
if (!user) {
throw new ForbiddenException('Authentication required');
}
// Витягування service з контексту
const controllerClass = context.getClass();
const serviceName = this.getServiceName(controllerClass.name);
const service = this.moduleRef.get(serviceName, { strict: false });
// Завантаження ресурсу
const resource = await service.findOne(resourceId);
if (!resource) {
throw new NotFoundException('Resource not found');
}
// Перевірка власника
const userIdField = options.userIdField || 'userId';
const resourceOwnerId = resource[userIdField];
if (resourceOwnerId !== user.id) {
throw new ForbiddenException('You can only modify your own resources');
}
return true;
}
private getServiceName(controllerName: string): string {
// PostsController -> PostsService
return controllerName.replace('Controller', 'Service');
}
}
Використання:
@Controller('posts')
@UseGuards(JwtAuthGuard, OwnerOnlyGuard)
export class PostsController {
@Patch(':id')
@OwnerOnly({ resourceParam: 'id', userIdField: 'authorId' })
update(@Param('id') id: string, @Body() dto: UpdatePostDto, @CurrentUser() user: User) {
// Guard перевірить чи user.id === post.authorId
return this.postsService.update(id, dto);
}
@Delete(':id')
@OwnerOnly({ resourceParam: 'id', userIdField: 'authorId' })
remove(@Param('id') id: string) {
return this.postsService.remove(id);
}
}
@Cached з динамічним ключем
// decorators/cached.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CACHE_KEY_METADATA = 'cacheKey';
export const CACHE_TTL_METADATA = 'cacheTTL';
export interface CachedOptions {
ttl: number; // Час життя кешу в секундах
keyFactory?: string; // Назва методу для генерації ключа
}
export const Cached = (options: CachedOptions) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
SetMetadata(CACHE_TTL_METADATA, options.ttl)(target, propertyKey, descriptor);
if (options.keyFactory) {
SetMetadata(CACHE_KEY_METADATA, options.keyFactory)(target, propertyKey, descriptor);
}
};
};
Interceptor для кешування:
// interceptors/cache.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_KEY_METADATA, CACHE_TTL_METADATA } from '../decorators/cached.decorator';
interface CacheEntry {
data: any;
expiresAt: number;
}
@Injectable()
export class CacheInterceptor implements NestInterceptor {
private cache = new Map<string, CacheEntry>();
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ttl = this.reflector.get<number>(CACHE_TTL_METADATA, context.getHandler());
if (!ttl) {
return next.handle();
}
const keyFactory = this.reflector.get<string>(CACHE_KEY_METADATA, context.getHandler());
const cacheKey = this.generateCacheKey(context, keyFactory);
// Перевірка кешу
const cached = this.cache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return of(cached.data);
}
// Виконання та кешування
return next.handle().pipe(
tap(data => {
this.cache.set(cacheKey, {
data,
expiresAt: Date.now() + ttl * 1000,
});
}),
);
}
private generateCacheKey(context: ExecutionContext, keyFactory?: string): string {
const request = context.switchToHttp().getRequest();
const controllerName = context.getClass().name;
const handlerName = context.getHandler().name;
if (keyFactory) {
const instance = context.getClass().prototype;
if (typeof instance[keyFactory] === 'function') {
return instance[keyFactory](request);
}
}
// За замовчуванням: controller:handler:url
return `${controllerName}:${handlerName}:${request.url}`;
}
}
Використання:
@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductsController {
@Get()
@Cached({ ttl: 300 }) // 5 хвилин
findAll() {
return this.productsService.findAll();
}
@Get(':id')
@Cached({ ttl: 600, keyFactory: 'generateProductCacheKey' })
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
// Кастомна функція для генерації ключа
private generateProductCacheKey(request: any): string {
return `product:${request.params.id}`;
}
}
@ValidateOwnership для автоматичної перевірки
// decorators/validate-ownership.decorator.ts
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { OwnershipGuard } from '../guards/ownership.guard';
export const OWNERSHIP_CONFIG_KEY = 'ownershipConfig';
export interface OwnershipConfig {
entity: string; // Назва entity (Post, Comment)
paramName: string; // Назва параметра з ID
ownerField: string; // Поле власника у entity
}
export function ValidateOwnership(config: OwnershipConfig) {
return applyDecorators(
SetMetadata(OWNERSHIP_CONFIG_KEY, config),
UseGuards(OwnershipGuard),
);
}
Використання:
@Controller('posts')
export class PostsController {
@Patch(':postId')
@ValidateOwnership({
entity: 'Post',
paramName: 'postId',
ownerField: 'authorId'
})
updatePost(
@Param('postId') postId: string,
@Body() dto: UpdatePostDto,
@CurrentUser('id') userId: string,
) {
return this.postsService.update(postId, dto);
}
}
@Controller('comments')
export class CommentsController {
@Delete(':commentId')
@ValidateOwnership({
entity: 'Comment',
paramName: 'commentId',
ownerField: 'userId'
})
deleteComment(@Param('commentId') commentId: string) {
return this.commentsService.remove(commentId);
}
}
Тестування кастомних декораторів
Unit-тестування Parameter Decorator
import { ExecutionContext } from '@nestjs/common';
import { CurrentUser } from './current-user.decorator';
describe('CurrentUser Decorator', () => {
let mockExecutionContext: ExecutionContext;
beforeEach(() => {
mockExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
user: { id: 1, email: 'test@example.com', roles: ['user'] },
}),
}),
} as any;
});
it('should return full user object when no field specified', () => {
const result = CurrentUser(null, mockExecutionContext);
expect(result).toEqual({
id: 1,
email: 'test@example.com',
roles: ['user'],
});
});
it('should return specific field when field name provided', () => {
const result = CurrentUser('email', mockExecutionContext);
expect(result).toBe('test@example.com');
});
it('should return undefined for non-existent field', () => {
const result = CurrentUser('nonExistent', mockExecutionContext);
expect(result).toBeUndefined();
});
});
Unit-тестування Metadata Decorator з Guard
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
import { ROLES_KEY } from './roles.decorator';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile();
guard = module.get(RolesGuard);
reflector = module.get(Reflector);
});
it('should allow access when no roles required', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockContext({ user: { roles: ['user'] } });
expect(guard.canActivate(context)).toBe(true);
});
it('should deny access when user lacks required role', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = createMockContext({ user: { roles: ['user'] } });
expect(guard.canActivate(context)).toBe(false);
});
it('should allow access when user has required role', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin', 'user']);
const context = createMockContext({ user: { roles: ['admin'] } });
expect(guard.canActivate(context)).toBe(true);
});
function createMockContext(request: any): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => request }),
getHandler: () => ({}),
getClass: () => ({}),
} as any;
}
});
E2E-тестування з декораторами
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('Custom Decorators (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Отримання токену для тестів
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@example.com', password: 'password' });
authToken = loginResponse.body.token;
});
afterAll(async () => {
await app.close();
});
describe('@CurrentUser decorator', () => {
it('should extract user from JWT token', () => {
return request(app.getHttpServer())
.get('/users/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect(res => {
expect(res.body).toHaveProperty('id');
expect(res.body).toHaveProperty('email');
});
});
it('should return 401 without token', () => {
return request(app.getHttpServer())
.get('/users/profile')
.expect(401);
});
});
describe('@Roles decorator', () => {
it('should allow access for admin role', () => {
return request(app.getHttpServer())
.get('/admin/users')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
});
it('should deny access for non-admin user', async () => {
const userLoginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'user@example.com', password: 'password' });
const userToken = userLoginResponse.body.token;
return request(app.getHttpServer())
.get('/admin/users')
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
});
describe('@RateLimit decorator', () => {
it('should block after exceeding rate limit', async () => {
const endpoint = '/api/limited';
// Зробити 5 запитів (ліміт)
for (let i = 0; i < 5; i++) {
await request(app.getHttpServer()).get(endpoint).expect(200);
}
// 6-й запит має бути заблокований
return request(app.getHttpServer())
.get(endpoint)
.expect(429)
.expect(res => {
expect(res.body.message).toContain('Rate limit exceeded');
expect(res.body).toHaveProperty('retryAfter');
});
});
});
});
Візуалізація потоку виконання декораторів
graph TD
A[HTTP Request] --> B{Route Handler}
B --> C[Parameter Decorators]
B --> D[Metadata Decorators]
C --> C1[@CurrentUser]
C --> C2[@Cookie]
C --> C3[@Headers]
C1 --> E[createParamDecorator]
C2 --> E
C3 --> E
E --> F[ExecutionContext]
F --> G[switchToHttp]
G --> H[getRequest]
H --> I[Extract Data]
I --> J[Return to Parameter]
D --> D1[@Roles]
D --> D2[@Public]
D --> D3[@Permissions]
D1 --> K[SetMetadata]
D2 --> K
D3 --> K
K --> L[Attach Metadata]
L --> M[Guard/Interceptor]
M --> N[Reflector.get]
N --> O[Read Metadata]
O --> P[Apply Logic]
J --> Q[Handler Execution]
P --> Q
Q --> R[Response]
style C1 fill:#a8dadc
style C2 fill:#a8dadc
style C3 fill:#a8dadc
style D1 fill:#f4a261
style D2 fill:#f4a261
style D3 fill:#f4a261
Опис потоку:
Parameter Decorators (блакитні):
@CurrentUser,@Cookie,@Headers→createParamDecorator()- Доступ до
ExecutionContext→switchToHttp()→getRequest() - Витягування даних з request
- Повернення значення у параметр handler
Metadata Decorators (помаранчеві):
@Roles,@Public,@Permissions→SetMetadata()- Прикріплення метаданих до handler/class
- Guard/Interceptor →
Reflector.get()→ читання метаданих - Застосування логіки (перевірка ролей, пропуск аутентифікації)
Найкращі практики кастомних декораторів
1. Типізуйте декоратори
export const CurrentUser = createParamDecorator(
(data, ctx) => { // data: any, ctx: any
return ctx.switchToHttp().getRequest().user;
},
);
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext): User | any => {
const request = ctx.switchToHttp().getRequest<Request>();
const user = request.user as User;
return data ? user?.[data] : user;
},
);
2. Використовуйте константи для ключів метаданих
@SetMetadata('rolesList', ['admin'])
// ...
const roles = this.reflector.get('rolesList', handler); // Опечатка!
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// Використання
const roles = this.reflector.get<string[]>(ROLES_KEY, handler);
3. Документуйте складні декоратори
/**
* Витягує поточного користувача з request.user
*
* @param field - Опціональна назва поля для витягування (id, email, roles)
* @returns Повний об'єкт User або конкретне поле
*
* @example
* // Витягування всього об'єкта
* getProfile(@CurrentUser() user: User) {}
*
* @example
* // Витягування конкретного поля
* getUserId(@CurrentUser('id') userId: string) {}
*/
export const CurrentUser = createParamDecorator(
(field: keyof User | undefined, ctx: ExecutionContext): User | any => {
// ...
},
);
4. Валідуйте дані у декораторах
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new UnauthorizedException('User not found in request');
}
return data ? user[data as string] : user;
},
);
5. Композиція замість дублювання
@Get('admin/users')
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
getUsers() {}
@Get('admin/posts')
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
getPosts() {}
export const AdminOnly = () => applyDecorators(
Roles('admin'),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
);
@Get('admin/users')
@AdminOnly()
getUsers() {}
@Get('admin/posts')
@AdminOnly()
getPosts() {}
6. Перевіряйте тип контексту
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
if (ctx.getType() !== 'http') {
throw new Error('CurrentUser decorator only works in HTTP context');
}
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
Підсумок
🎯 Parameter Decorators
Витягують дані з request через createParamDecorator(). Використовуються у параметрах методів (@CurrentUser, @Cookie, @Headers). Доступ до ExecutionContext.
Приклад:
export const CurrentUser = createParamDecorator(
(data, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user
);
🏷️ Metadata Decorators
Прикріплюють метадані через @SetMetadata(). Використовуються Guards/Interceptors для читання (@Roles, @Public, @Permissions). Витягуються через Reflector.
Приклад:
export const Roles = (...roles: string[]) =>
SetMetadata('roles', roles);
🧩 applyDecorators()
Композиція кількох декораторів в один. Скорочує дублювання коду. Комбінує Guards, metadata, Swagger декоратори.
Приклад:
export const Auth = (...roles: string[]) => applyDecorators(
Roles(...roles),
UseGuards(JwtAuthGuard, RolesGuard),
);
🔧 Decorator Factories
Функції, що повертають декоратори з параметрами. Підтримують конфігурацію через об'єкти options. Гнучкість та переконфігурованість.
Приклад:
export const Cached = (ttl: number) => SetMetadata('cacheTTL', ttl);
🔍 Reflector Integration
Витягування метаданих у Guards через Reflector. Методи: get(), getAllAndOverride(), getAllAndMerge(). Доступ до handler та class метаданих.
Приклад:
const roles = this.reflector.getAllAndOverride('roles', [
context.getHandler(),
context.getClass(),
]);
🔌 Pipes Integration
Parameter decorators можуть використовувати Pipes. Автоматична валідація та трансформація. Комбінування з ParseIntPipe, ValidationPipe.
Приклад:
getProfile(@CurrentUser(ParseUserIdPipe) userId: number) {}
⚡ Advanced Patterns
Умовні декоратори (залежно від environment). Типізовані декоратори з generic. Декоратори з складною логікою (RateLimit, OwnerOnly).
Приклад:
export const ConditionalAuth = () =>
process.env.NODE_ENV === 'production'
? UseGuards(JwtAuthGuard)
: () => {};
🧪 Testing Decorators
Unit-тести з мокуванням ExecutionContext. Тестування Guards з Reflector через jest.spyOn(). E2E-тести для перевірки реальної поведінки.
Приклад:
jest.spyOn(reflector, 'get').mockReturnValue(['admin']);
const context = { switchToHttp: () => ({ getRequest: () => ({}) }) };
Часті запитання (FAQ)
Parameter Decorator (через createParamDecorator()):
- Витягує дані з request для параметрів методу
- Виконується під час обробки запиту
- Приклади:
@CurrentUser(),@Cookie(),@Headers() - Результат передається у параметр handler
Metadata Decorator (через @SetMetadata()):
- Прикріплює метадані до класу/методу
- Виконується під час завантаження модуля (compile time)
- Приклади:
@Roles(),@Public(),@Permissions() - Читається Guards/Interceptors через
Reflector
Ні безпосередньо, але є workaround:
// ❌ Не працює - DI недоступний у createParamDecorator
export const CurrentTenant = createParamDecorator(
(data, ctx: ExecutionContext) => {
const tenantService = ???; // Немає доступу до DI
},
);
// ✅ Рішення: використати Interceptor з DI
@Injectable()
export class TenantInterceptor implements NestInterceptor {
constructor(private tenantService: TenantService) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
// Витягування через DI
request.tenant = await this.tenantService.findById(tenantId);
return next.handle();
}
}
// Декоратор лише витягує з request
export const CurrentTenant = createParamDecorator(
(data, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().tenant,
);
Використовуйте об'єкт як параметр data:
interface ExtractOptions {
fields: string[];
}
export const ExtractUser = createParamDecorator(
(options: ExtractOptions, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (options?.fields) {
return options.fields.reduce((acc, field) => {
acc[field] = user[field];
return acc;
}, {});
}
return user;
},
);
// Використання
@Get('info')
getInfo(@ExtractUser({ fields: ['id', 'email', 'roles'] }) data) {
// data = { id: 1, email: '...', roles: [...] }
return data;
}
Так, застосуйте декоратор на рівні класу:
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin') // Застосується до всіх методів
export class AdminController {
@Get('users')
getUsers() {} // Потрібна роль 'admin'
@Get('posts')
getPosts() {} // Потрібна роль 'admin'
@Get('public')
@Roles('user') // Override на рівні методу
getPublic() {} // Потрібна роль 'user' (не 'admin')
}
Використовуйте фабрику декораторів:
export function ConditionalGuard(condition: () => boolean, guard: any) {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (condition()) {
return UseGuards(guard)(target, propertyKey, descriptor);
}
return descriptor;
};
}
// Використання
@Get('debug')
@ConditionalGuard(
() => process.env.NODE_ENV === 'production',
JwtAuthGuard
)
getDebugInfo() {
// Auth лише у production
}
Так, вони працюють незалежно:
@Controller('posts')
export class PostsController {
@Get('my')
@Roles('user') // Metadata decorator
getMyPosts(@CurrentUser('id') userId: string) { // Parameter decorator
return this.postsService.findByUserId(userId);
}
}
Порядок виконання:
- Metadata декоратори встановлюють метадані (compile time)
- Guards читають метадані через Reflector (runtime)
- Parameter декоратори витягують дані з request (runtime)
- Handler виконується з параметрами
Тестуйте окремо кожен компонент або через E2E:
// Unit-тестування компонентів
describe('Auth Composite Decorator', () => {
it('should apply all decorators', () => {
const decorators = Auth('admin');
// Перевірка що applyDecorators повертає функцію
expect(typeof decorators).toBe('function');
});
});
// E2E-тестування
describe('Auth (e2e)', () => {
it('should protect endpoint with @Auth decorator', () => {
return request(app.getHttpServer())
.get('/admin/users')
.expect(401); // Без токена
});
it('should allow access with valid token and role', () => {
return request(app.getHttpServer())
.get('/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
});
});
Викидайте виключення безпосередньо:
export const RequiredQuery = createParamDecorator(
(paramName: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const value = request.query[paramName];
if (!value) {
throw new BadRequestException(`Missing required parameter: ${paramName}`);
}
return value;
},
);
// Використання
@Get('search')
searchProducts(@RequiredQuery('category') category: string) {
// Гарантовано category існує
return this.productsService.search(category);
}
Ні безпосередньо, але можна повернути Promise:
export const CurrentTenantAsync = createParamDecorator(
async (data, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
// Async операція (але краще робити в Interceptor/Guard)
const tenant = await fetchTenantFromCache(tenantId);
return tenant;
},
);
// Handler має бути async
@Get('data')
async getData(@CurrentTenantAsync() tenant: Tenant) {
return this.service.getDataForTenant(tenant);
}
Рекомендація: для async операцій використовуйте Interceptor замість декоратора.
У наступній лекції 18. Global Components ми розглянемо глобальну реєстрацію компонентів через APP_* токени, порядок виконання глобальних Guards/Interceptors/Pipes/Filters та best practices для організації глобальних компонентів.