Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як створити та використовувати власні декоратори в NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Власні декоратори в NestJS (custom decorators)** - це TypeScript-функції, що або витягують значення з контексту запиту, або зберігають метадані для guards та pipes. ```typescript export const CurrentUser = createParamDecorator( (data: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return data ? request.user?.[data] : request.user; }, ); ``` **Ключова ідея:** `createParamDecorator` для витягування даних із запиту, `SetMetadata` для метаданих guard, `applyDecorators` щоб об'єднати обидва підходи в один декоратор.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Власні декоратори в NestJS (custom decorators)** - це TypeScript-функції, що або витягують значення з контексту запиту, або зберігають метадані для guards і pipes через `Reflect.metadata`. ## Теорія ### TL;DR - Декоратор схожий на ярлик на деталі конвеєра: прикріплюєш його до методу або параметра, і NestJS зчитує його під час виконання та автоматично змінює поведінку - Два основних типи: param-декоратори (витягують дані із запиту) і metadata-декоратори (зберігають дані для guards та pipes) - `createParamDecorator` для витягування із запиту, `SetMetadata` для метаданих, `applyDecorators` щоб об'єднати обидва підходи - Правило: якщо копіюєш `req.user` або `SetMetadata('roles', ...)` у 2+ місцях - час створити декоратор. Для одноразової логіки - використовуй guards або pipes напряму ### Швидкий приклад ```typescript import { createParamDecorator, ExecutionContext } from '@nestjs/common'; export const CurrentUser = createParamDecorator( (data: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user; // заповнюється JwtAuthGuard return data ? user?.[data] ?? null : user ?? null; }, ); // Використання в контролері @Get('me') getMe(@CurrentUser() user: User) { return user; // повний об'єкт користувача } @Get('email') getEmail(@CurrentUser('email') email: string) { return { email }; // тільки email } ``` `data` - це аргумент всередині виклику декоратора. `@CurrentUser('email')` передає рядок `'email'` як `data`. Без аргументу повертається весь об'єкт. Повертати `null` замість `undefined` варто, щоб типи TypeScript у контролері не вводили в оману. ### Головна різниця: param vs metadata декоратори Param-декоратори запускаються для кожного запиту всередині `createParamDecorator` і витягують значення з `ExecutionContext`. Metadata-декоратори запускаються один раз під час визначення класу через `SetMetadata` і зберігають пари ключ-значення, які guards зчитують пізніше за допомогою `Reflector`. Якщо плутати ці підходи, метадані виявляються `undefined` у рантаймі - і guard непомітно блокує або пропускає все підряд. ### Коли використовувати - Повторне витягування `request.user` у 2+ контролерах: param-декоратор - Перевірка ролей та прав доступу на кількох маршрутах: metadata-декоратор разом із guard - Власна валідація поля: property-декоратор з `registerDecorator` - Чотири декоратори на кожному захищеному endpoint: composed-декоратор через `applyDecorators` - Одноразова логіка: використовуй guards або pipes напряму, без декоратора ### Типи декораторів та їх API **Param-декоратор** запускається для кожного запиту і отримує `ExecutionContext`: ```typescript // 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; return data ? user?.[data] ?? null : user ?? null; }, ); ``` **Metadata-декоратор** зберігає статичні дані, які guards зчитують пізніше: ```typescript // roles.decorator.ts import { SetMetadata } from '@nestjs/common'; export const ROLES_KEY = 'roles'; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); ``` Guard зчитує це через `Reflector.getAllAndOverride`: ```typescript // roles.guard.ts @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)); } } ``` **Composed-декоратор** об'єднує кілька декораторів в один виклик: ```typescript // auth.decorator.ts import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common'; import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger'; export function Auth(...roles: string[]) { return applyDecorators( SetMetadata(ROLES_KEY, roles), UseGuards(JwtAuthGuard, RolesGuard), ApiBearerAuth(), ApiUnauthorizedResponse({ description: 'Unauthorized' }), ); } ``` Замість чотирьох декораторів на кожному захищеному маршруті - один `@Auth('admin')`. **Property-декоратор** для власних правил валідації через `class-validator`: ```typescript import { registerDecorator, ValidationOptions } from 'class-validator'; export function IsStrongPassword(validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ name: 'isStrongPassword', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: string) { return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/.test(value); }, defaultMessage() { return 'Пароль має містити великі та малі літери, цифри та спеціальні символи'; }, }, }); }; } ``` ### Як це працює всередині TypeScript компілює декоратори як статичні виклики функцій, що запускаються коли клас визначається, а не коли приходить запит. Вони зберігають пари ключ-значення через `Reflect.defineMetadata` у WeakMap, прив'язаному до класу або методу. NestJS сканує ці дані під час запуску застосунку і підключає метадані до системи DI. Коли приходить запит, guards та pipes викликають `Reflect.getMetadata`, щоб прочитати збережене. `Reflector.getAllAndOverride` в guards перевіряє метадані на рівні методу першими, потім на рівні класу. Метод перемагає, якщо визначені обидва. Саме тому можна встановити `@Roles('admin')` на контролер і перевизначити конкретний маршрут через `@Roles('user')` - ближчий рівень завжди виграє. ### Типові помилки **Припускати HTTP-контекст у всіх param-декораторах:** ```typescript // Неправильно: падає у WebSocket або мікросервісах export const UserId = createParamDecorator( (_, ctx) => ctx.switchToHttp().getRequest().user.id ); // Правильно: спочатку перевір тип контексту export const UserId = createParamDecorator( (_, ctx) => { if (ctx.getType() === 'http') { return ctx.switchToHttp().getRequest().user?.id; } return ctx.switchToRpc().getData().user?.id; } ); ``` **Повертати `undefined`, коли користувач відсутній:** ```typescript // Неправильно: типи кажуть User, рантайм дає undefined -> помилки в продакшені export const CurrentUser = createParamDecorator( (_, ctx) => ctx.switchToHttp().getRequest().user ); // Правильно: явно повертай null, щоб контролер знав що обробляти export const CurrentUser = createParamDecorator( (_, ctx) => ctx.switchToHttp().getRequest().user ?? null ); ``` **Відсутній поліфіл `reflect-metadata`:** NestJS автоматично імпортує `reflect-metadata` у `main.ts`. Але якщо ти переносиш декоратор у спільну бібліотеку без цього імпорту, `Reflector.getMetadata` скрізь повертає `undefined` і guards перестають працювати. Перевіряй, що `import 'reflect-metadata'` виконується до будь-якого коду з декораторами. **Конфлікт назв із вбудованими декораторами NestJS:** ```typescript // Неправильно: перекриває @Body() з @nestjs/common, ламає validation pipes export const Body = createParamDecorator(...); // Правильно: унікальний префікс export const ParsedBody = createParamDecorator(...); ``` **Використовувати `SetMetadata` напряму замість іменованої константи:** ```typescript // Неправильно: легко зробити опечатку, складно рефакторити @SetMetadata('roles', ['admin']) // Правильно: виноси ключ у константу, використовуй і в декораторі, і в guard export const ROLES_KEY = 'roles'; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); ``` ### Де зустрічається в реальних проектах - `@nestjs/passport` використовує `@User()` param-декоратор для витягування JWT-користувача - NestJS GraphQL: `@CurrentUser()` у resolver-ах з `GqlExecutionContext` замість HTTP-контексту - E-commerce бекенди (замовлення, інвентар): `@Roles()` з `RolesGuard` для контролю доступу - API-платформи: `@Auth()` composed-декоратор, що прикріплює `@UseGuards`, `@ApiBearerAuth()` та `@SetMetadata` одним рядком і тримає Swagger-документацію актуальною ### Питання для співбесіди **Q:** Яка різниця між `createParamDecorator` і методним декоратором? **A:** `createParamDecorator` запускається для кожного запиту і витягує значення з `ExecutionContext`. Методний декоратор запускається один раз під час визначення класу - зазвичай щоб встановити статичні метадані через `SetMetadata`. **Q:** Чи може фабрична функція всередині `createParamDecorator` бути async? **A:** Так. Функція може використовувати `await`, наприклад для запиту до бази даних. Вона запускається після завершення guards, тому `request.user` вже заповнений, якщо auth guard виконався. **Q:** Як написати unit-тест для власного param-декоратора? **A:** Створи mock `ExecutionContext` із потрібними даними запиту, потім виклич фабричну функцію декоратора напряму. Повний застосунок NestJS не потрібен. **Q:** Чому `Reflector.getAllAndOverride` перевіряє і handler, і клас? **A:** Він обходить ланцюжок метаданих і бере перше визначене значення. Це дозволяє встановити `@Roles('admin')` на контролер і перевизначити конкретні маршрути - ближчий рівень завжди перемагає. **Q (senior):** Якщо `@Roles('admin')` стоїть на класі контролера, а `@Roles('user')` на методі, що поверне `getAllAndOverride`? **A:** Поверне `['user']`. `getAllAndOverride` починає з handler (методу) і бере перше визначене значення, тому метадані методу завжди перемагають метадані класу. ## Приклади ### Базовий: CurrentUser param-декоратор ```typescript // 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; // встановлюється JwtAuthGuard до запуску декоратора return data ? user?.[data] ?? null : user ?? null; }, ); // profile.controller.ts @Controller('profile') @UseGuards(JwtAuthGuard) export class ProfileController { @Get() getMe(@CurrentUser() user: User) { return user; } @Get('email') getEmail(@CurrentUser('email') email: string) { return { email }; } } ``` Аргумент всередині виклику декоратора потрапляє в `data`. Без аргументу отримуєш весь об'єкт користувача. Повернення `null` замість `undefined` робить TypeScript-типи у контролері чесними. ### Середній: Декоратор Roles з RolesGuard ```typescript // roles.decorator.ts import { SetMetadata } from '@nestjs/common'; export const ROLES_KEY = 'roles'; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); // roles.guard.ts @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)); } } // orders.controller.ts @Controller('orders') @UseGuards(JwtAuthGuard, RolesGuard) export class OrdersController { @Post() @Roles('customer', 'admin') // 403 якщо user.roles не містить жодної з ролей create(@Body() dto: CreateOrderDto) { return this.ordersService.create(dto); } @Delete(':id') @Roles('admin') // перевизначає метадані класу, якщо вони там є remove(@Param('id') id: string) { return this.ordersService.remove(id); } } ``` `getAllAndOverride` спочатку перевіряє `getHandler()` (метод), потім `getClass()` (контролер). Якщо визначено лише одне місце, береться воно. ### Просунутий: Composed-декоратор Auth ```typescript // auth.decorator.ts import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common'; import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RolesGuard } from './guards/roles.guard'; export const ROLES_KEY = 'roles'; export function Auth(...roles: string[]) { return applyDecorators( SetMetadata(ROLES_KEY, roles), UseGuards(JwtAuthGuard, RolesGuard), ApiBearerAuth(), ApiUnauthorizedResponse({ description: 'Unauthorized' }), ); } // users.controller.ts @Controller('users') export class UsersController { @Get() @Auth('admin') findAll() { return this.usersService.findAll(); } @Get('me') @Auth('customer', 'admin') getProfile(@CurrentUser() user: User) { return user; } } ``` Без `@Auth` кожен маршрут потребує чотирьох окремих декораторів. З ним контролер залишається читабельним, а Swagger-документація оновлюється автоматично на кожному новому маршруті. Я бачив проекти де `@ApiBearerAuth()` забували додати на новий endpoint, і це ламало генерацію OpenAPI-клієнта. Цей патерн робить таку помилку неможливою.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.