Тема 9. Валідація даних та конвеєр обробки запитів у NestJS

Custom Decorators: власні декоратори

Parameter decorators, metadata decorators, composition, advanced patterns

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 без зміни сигнатури методів.

Parameter decorators використовують 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;
}

Витягування конкретного поля з 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
  }
}
// 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 [];
}

Композитний декоратор @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 (блакитні):

  1. @CurrentUser, @Cookie, @Headers → createParamDecorator()
  2. Доступ до ExecutionContext → switchToHttp() → getRequest()
  3. Витягування даних з request
  4. Повернення значення у параметр handler

Metadata Decorators (помаранчеві):

  1. @Roles, @Public, @Permissions → SetMetadata()
  2. Прикріплення метаданих до handler/class
  3. Guard/Interceptor → Reflector.get() → читання метаданих
  4. Застосування логіки (перевірка ролей, пропуск аутентифікації)

Найкращі практики кастомних декораторів

1. Типізуйте декоратори

export const CurrentUser = createParamDecorator(
  (data, ctx) => { // data: any, ctx: any
    return ctx.switchToHttp().getRequest().user;
  },
);

2. Використовуйте константи для ключів метаданих

@SetMetadata('rolesList', ['admin'])
// ...
const roles = this.reflector.get('rolesList', 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() {}

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)


У наступній лекції 18. Global Components ми розглянемо глобальну реєстрацію компонентів через APP_* токени, порядок виконання глобальних Guards/Interceptors/Pipes/Filters та best practices для організації глобальних компонентів.

Copyright © 2026