Кастомные декораторы для NestJS: от простого к сложному

    image


    Введение


    NestJS — стремительно набирающий популярность фрeймворк, построенный на идеях IoC/DI, модульного дизайна и декораторов. Благодаря последним, Nest имеет лаконичный и выразительный синтаксис, что повышает удобство разработки.


    Декораторы или аннотации — наследники аспектов, которые позволяют декларативно описывать логику, модифицировать поведение классов, их свойств, аргументов и методов.


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


    Базовые декораторы


    Возьмем простейший http-контроллер. Допустим, нам требуется, чтобы только определенные пользователи могли воспользоваться его методами. Для этого кейса в Nest есть встроенная функциональность гардов.


    Guard — это комбинация класса, реализующего интерфейс CanActivate и декоратора @UseGuard.


    @Injectable()
    export class RoleGuard implements CanActivate {
      canActivate(
        context: ExecutionContext,
      ): boolean | Promise<boolean> | Observable<boolean> {
        const request = context.switchToHttp().getRequest();
        return getRole(request) === 'superuser'
      }
    }
    
    @Controller()
    export class MyController {
      @Post('secure-path')
      @UseGuards(RoleGuard)
      async method() {
        return
      }
    }

    Захардкоженный superuser — не самое лучшее решение, куда чаще нужны более универсальные декораторы.


    Nest в этом случае предлагает использовать декоратор @SetMetadata. Как понятно из названия, он позволяет ассоциировать метаданные с декорируемыми объектами — классами или методами.


    Для доступа к этим данным используется экземпляр класса Reflector, но можно и напрямую через reflect-metadata.


    @Injectable()
    export class RoleGuard implements CanActivate {
      constructor(private reflector: Reflector) {}
      canActivate(
        context: ExecutionContext,
      ): boolean | Promise<boolean> | Observable<boolean> {
        const role = this.reflector.get<string>('role', context.getHandler());
        const request = context.switchToHttp().getRequest();
        return getRole(request) === role
      }
    }
    
    @Controller()
    export class MyController {
      @Post('secure-path')
      @SetMetadata('role', 'superuser')
      @UseGuards(RoleGuard)
      async test() {
        return
      }
    }

    Композитные декораторы


    Декораторы зачастую применяются в связках.


    Обычно это обусловлено тесной связностью эффектов в каком-то бизнес-сценарии. В этом случае имеет смысл объединить несколько декораторов в один.


    Для композиции можно воспользоваться утилитной функцией applyDecorators.


    const Role = (role) => applyDecorators(UseGuards(RoleGuard), SetMetadata('role', role))

    или написать агрегатор самим:


    const Role = role => (proto, propName, descriptor) => {
      UseGuards(RoleGuard)(proto, propName, descriptor)
      SetMetadata('role', role)(proto, propName, descriptor)
    }
    
    @Controller()
    export class MyController {
      @Post('secure-path')
      @Role('superuser')
      async test() {
        return
      }
    }

    Полиморфные декораторы


    Легко столкнуться с ситуацией, когда оказывается нужным задекорировать все методы класса.


    @Controller()
    @UseGuards(RoleGuard)
    export class MyController {
      @Post('secure-path')
      @Role('superuser')
      async test1() {
        return
      }
    
      @Post('almost-securest-path')
      @Role('superuser')
      async test2() {
        return
      }
    
      @Post('securest-path')
      @Role('superuser')
      async test3() {
        return
      }
    }

    Такой код можно сделать чище, если повесить декоратор на сам класс. И уже внутри декоратора класса обойти прототип, применяя эффекты на все методы, как если бы декораторы были повешены на каждый метод по-отдельности.


    Однако для этого обработчику необходимо различать типы объектов применения — класс и метод — и в зависимости от этого выбирать поведение.


    Реализация декораторов в typescript не содержит этот признак в явном виде, поэтому его приходится выводить из сигнатуры вызова.


    type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
    type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
    type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
    
    const Role = (role: string): MethodDecorator | ClassDecorator => (...args) => {
      if (typeof args[0] === 'function') {
        // Получение конструктора
        const ctor = args[0]
        // Получение прототипа
        const proto = ctor.prototype
        // Получение методов
        const methods = Object
          .getOwnPropertyNames(proto)
          .filter(prop => prop !== 'constructor')
    
        // Обход и декорирование методов
        methods.forEach((propName) => {
          RoleMethodDecorator(
            proto,
            propName,
            Object.getOwnPropertyDescriptor(proto, propName),
            role,
          )
        })
      } else {
        const [proto, propName, descriptor] = args
        RoleMethodDecorator(proto, propName, descriptor, role)
      }
    }

    Есть вспомогательные библиотеки, которые берут на себя часть этой рутины: lukehorvat/decorator-utils, qiwi/decorator-utils.
    Это несколько улучшает читаемость.


    import { constructDecorator, CLASS, METHOD } from '@qiwi/decorator-utils'
    
    const Role = constructDecorator(
      ({ targetType, descriptor, proto, propName, args: [role] }) => {
        if (targetType === METHOD) {
          RoleMethodDecorator(proto, propName, descriptor, role)
        }
    
        if (targetType === CLASS) {
          const methods = Object.getOwnPropertyNames(proto)
          methods.forEach((propName) => {
            RoleMethodDecorator(
              proto,
              propName,
              Object.getOwnPropertyDescriptor(proto, propName),
              role,
            )
          })
        }
      },
    )

    Совмещение в одном декораторе логики для разных сценариев дает очень весомый плюс для разработки:
    вместо @DecForClass, @DecForMethood, @DecForParam получается всего один многофункциональный @Dec.


    Так, например, если роль пользователя вдруг потребуется в бизнес-слое контроллера, можно просто расширить логику @Role.


    Добавляем в ранее написанную функцию обработку сигнатуры декоратора параметра.
    Так как подменить значение параметров вызова напрямую нельзя, createParamDecorator делегирует это вышестоящему декоратору посредством метаданных.


    И далее именно декоратор метода / класса будет резолвить аргументы вызова (через очень длинную цепочку от ParamsTokenFactory до RouterExecutionContext).


    // Сигнатура параметра
      if (typeof args[2] === 'number') {
        const [proto, propName, paramIndex] = args
        createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
          return getRole(ctx.switchToHttp().getRequest())
        })()(proto, propName, paramIndex)
      }

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


    Предположим, нам потребовалось ограничение размера запроса, и соответствующий декоратор повесили дважды. Какому значению доверять?


    Без знания логики компилятора возникает неопределенность. Правильнее, наверное, было бы бросить ошибку.


    class SomeController {
       @RequestSize(1000)
       @RequestSize(5000)
       @Post('foo')
       method(@Body() body) {
       }
    }

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


    class SomeController {
       @Port(9092)
       @Port(8080)
       @Post('foo')
       method(@Body() body) {
       }
    }

    Схожая ситуация возникает с ролевой моделью.


    class SomeController {
      @Post('securest-path')
      @Role('superuser')
      @Role('usert')
      @Role('otheruser')
      method(@Role() role) {
    
      }
    }

    Обобщая рассуждения, реализация декоратора для последнего примера с использованием reflect-metadata и полиморфного контракта может иметь вид:


    import { ExecutionContext, createParamDecorator } from '@nestjs/common'
    import { constructDecorator, METHOD, PARAM } from '@qiwi/decorator-utils'
    
    @Injectable()
    export class RoleGuard implements CanActivate {
      canActivate(context: ExecutionContext): boolean | Promise<boolean> {
        const roleMetadata = Reflect.getMetadata(
          'roleMetadata',
          context.getClass().prototype,
        )
        const request = context.switchToHttp().getRequest()
        const role = getRole(request)
        return roleMetadata.find(({ value }) => value === role)
      }
    }
    
    const RoleMethodDecorator = (proto, propName, decsriptor, role) => {
      UseGuards(RoleGuard)(proto, propName, decsriptor)
      const meta = Reflect.getMetadata('roleMetadata', proto) || []
    
      Reflect.defineMetadata(
        'roleMetadata',
        [
          ...meta, {
            repeatable: true,
            value: role,
          },
        ],
        proto,
      )
    }
    
    export const Role = constructDecorator(
      ({ targetType, descriptor, proto, propName, paramIndex, args: [role] }) => {
        if (targetType === METHOD) {
          RoleMethodDecorator(proto, propName, descriptor, role)
        }
    
        if (targetType === PARAM) {
          createParamDecorator((_data: unknown, ctx: ExecutionContext) =>
            getRole(ctx.switchToHttp().getRequest()),
          )()(proto, propName, paramIndex)
        }
      },
    )

    Макродекораторы


    Nest спроектирован таким образом, что его собственные декораторы удобно расширять и переиспользовать. На первый взгляд довольно сложные кейсы, к примеру, связанные с добавлением поддержки новых протоколов, реализуются парой десятков строк обвязочного кода. Так, стандартный @Controller можно «обсахарить»
    для работы с JSON-RPC.
    Не будем останавливаться на этом подробно, это слишком бы далеко вышло за формат этой статьи, но покажу основную идею: на что способны декораторы, в сочетании с Nest.


    import {
      ControllerOptions,
      Controller,
      Post,
      Req,
      Res,
      HttpCode,
      HttpStatus,
    } from '@nestjs/common'
    
    import { Request, Response } from 'express'
    import { Extender } from '@qiwi/json-rpc-common'
    import { JsonRpcMiddleware } from 'expressjs-json-rpc'
    
    export const JsonRpcController = (
      prefixOrOptions?: string | ControllerOptions,
    ): ClassDecorator => {
      return <TFunction extends Function>(target: TFunction) => {
        const extend: Extender = (base) => {
          @Controller(prefixOrOptions as any)
          @JsonRpcMiddleware()
          class Extended extends base {
            @Post('/')
            @HttpCode(HttpStatus.OK)
            rpc(@Req() req: Request, @Res() res: Response): any {
              return this.middleware(req, res)
            }
          }
    
          return Extended
        }
    
        return extend(target as any)
      }
    }
    

    Далее необходимо извлечь @Req() из rpc-method в мидлваре, найти совпадение с метой, которую добавил декоратор @JsonRpcMethod.


    Готово, можно использовать:


    import {
      JsonRpcController,
      JsonRpcMethod,
      IJsonRpcId,
      IJsonRpcParams,
    } from 'nestjs-json-rpc'
    
    @JsonRpcController('/jsonrpc/endpoint')
    export class SomeJsonRpcController {
      @JsonRpcMethod('some-method')
      doSomething(
        @JsonRpcId() id: IJsonRpcId,
        @JsonRpcParams() params: IJsonRpcParams,
      ) {
        const { foo } = params
    
        if (foo === 'bar') {
          return new JsonRpcError(-100, '"foo" param should not be equal "bar"')
        }
    
        return 'ok'
      }
      @JsonRpcMethod('other-method')
      doElse(@JsonRpcId() id: IJsonRpcId) {
        return 'ok'
      }
    }
    

    Вывод


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


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

    QIWI
    Ведущий платёжный сервис нового поколения в России

    Комментарии 4

      +3
      Люблю NestJS. Декораторы кастомные не писал, спасибо за статью
        +1
        Декораторы конечно штука хорошая, просто в какой-то момент обнаруживаешь что код декораторов занимает больше места, чем код классов…
        Вот как-то так:
        Декораторы на декораторах сидят, декораторами погоняют
        @Crud({
          model: {
            type: User,
          },
          query: { alwaysPaginate: true },
          dto: { create: CreateUserRequestDto, update: UpdateUserRequestDto },
          serialize: { get: UserResponse, create: UserResponse, update: UserResponse },
          routes: {
            only: ['createOneBase', 'getOneBase', 'getManyBase', 'updateOneBase'],
            createOneBase: {
              decorators: [
                Roles('admin'),
                ApiBadRequestResponse(),
              ],
            },
            getOneBase: {
              decorators: [
                Roles('admin'),
                ApiBadRequestResponse(),
                ApiNotFoundResponse(),
              ],
            },
            getManyBase: {
              decorators: [
                Roles('admin'),
                ApiBadRequestResponse(),
                ApiNotFoundResponse(),
              ],
            },
            updateOneBase: {
              decorators: [
                Roles('admin'),
                ApiBadRequestResponse(),
                ApiNotFoundResponse(),
              ],
            },
          },
        })
        @ApiTags('user')
        @UseGuards(RoleGuard)
        @Controller('users')
        export class UsersController implements CrudController<User> {
          constructor(public service: UsersService) {}
        }
        

          +2
          Зашло, как раз разбираюсь. Спасибо за статью.
            +1
            Для NestJS сделал библиотеку — NestJS JSON RPC

            Если кому-то интересно, то можно посмотреть как это было сделано с помощью внутренностей неста благодаря которым можно сделать хоть кастомный роутинг на чем угодно

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

            Самое читаемое