Pipe - это класс, аннотированный декоратором @Injectable(). Pipe должен реализовывать интерфейс PipeTransform.

Pipes имеют два типичных случая использования:

  • трансформация: преобразование входных данных в нужную форму (например, из строки в целое число).
  • валидация: оценить входные данные и, если они верны, просто передать их без изменений; в противном случае, если данные неверны, выдать исключение.

В обоих случаях pipes работают с аргументами, обрабатываемыми обработчиком маршрута контроллера. Nest устанавливает pipe непосредственно перед вызовом метода, и этот pipe получает аргументы, предназначенные для метода, и работает с ними. Любая операция преобразования или проверки происходит в это время, после чего вызывается обработчик маршрута с любыми (потенциально) преобразованными аргументами.

Nest поставляется с рядом встроенных pipes, которые вы можете использовать "из коробки". Вы также можете создавать свои собственные pipes. В этой главе мы познакомимся со встроенными pipes и покажем, как привязать их к обработчикам маршрутов. Затем мы рассмотрим несколько пользовательских pipes, чтобы показать, как можно создать их с нуля.

Pipes работают внутри зоны исключений. Это означает, что когда Pipe выбрасывает исключение, оно обрабатывается уровнем исключений (глобальный фильтр исключений и любые фильтры исключений, которые применяются к текущему контексту). Учитывая вышесказанное, должно быть понятно, что когда исключение выбрасывается в Pipe, ни один метод контроллера впоследствии не выполняется. Это дает вам наилучшую методику проверки данных, поступающих в приложение из внешних источников.

Встроенные pipes

Nest поставляется с восемью pipes, доступными из коробки:

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe

Они экспортируются из пакета @nestjs/common.

Давайте рассмотрим использование ParseIntPipe. Это пример использования трансформации, где pipe обеспечивает преобразование параметра обработчика метода в целое число JavaScript (или выбрасывает исключение, если преобразование не удалось). Позже в этой главе мы покажем простую пользовательскую реализацию для ParseIntPipe. Приведенные ниже примеры также применимы к другим встроенным pipes для трансформации данных (ParseBoolPipe, ParseFloatPipe, ParseEnumPipe, ParseArrayPipe и ParseUUIDPipe, которые в этой главе мы будем называть семейством Parse*).

Привязка pipe

Чтобы использовать pipe, нам нужно привязать экземпляр класса pipe к соответствующему контексту. В нашем примере ParseIntPipe мы хотим связать pipe с определенным методом обработчика маршрута и убедиться, что он будет запущен до вызова метода. Мы сделаем это с помощью следующей конструкции, которую мы будем называть привязкой pipe на уровне параметров метода:

@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

Это гарантирует, что одно из следующих двух условий верно: либо параметр, который мы получаем в методе findOne(), является числом (как и ожидалось в нашем вызове this.catsService.findOne()), либо исключение будет выброшено до вызова обработчика маршрута.

Например, предположим, что маршрут вызывается следующим образом:

GET localhost:3000/abc

Nest выбросит исключение, подобное этому:

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

Исключение не позволит выполнить тело метода findOne().

В приведенном выше примере мы передаем класс (ParseIntPipe), а не экземпляр, оставляя ответственность за инстанцирование фреймворку и обеспечивая внедрение зависимостей. Как и в случае с pipes и guards, мы можем вместо этого передать экземпляр на месте. Передача экземпляра на месте полезна, если мы хотим настроить поведение встроенной pipe путем передачи опций:

@Get(':id')
async findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {
  return this.catsService.findOne(id);
}

Привязка других pipe преобразования (семейство Parse*) работает аналогично. Все эти pipes работают в контексте проверки параметров маршрута, параметров строки запроса и значений тела запроса.

Например, с параметром строки запроса:

@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

Вот пример использования ParseUUIDPipe для разбора строкового параметра и проверки, является ли он UUID.

@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
  return this.catsService.findOne(uuid);
}

При использовании ParseUUIDPipe() вы разбираете UUID версии 3, 4 или 5, если вам требуется только определенная версия UUID, вы можете передать версию в опциях pipe.

Выше мы рассмотрели примеры связывания различных встроенных pipe семейства Parse*. Привязка pipe валидации немного отличается; мы обсудим это в следующем разделе.

Пользовательские pipes

Как уже говорилось, вы можете создавать собственные пользовательские pipes. Хотя Nest предоставляет надежные встроенные ParseIntPipe и ValidationPipe, давайте построим простые пользовательские версии каждого из них с нуля, чтобы увидеть, как создаются пользовательские pipes.

Начнем с простого ValidationPipe. Изначально мы попросим его просто принимать входное значение и немедленно возвращать то же значение.

validation.pipe.ts

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

PipeTransform<T, R> - это общий интерфейс, который должен быть реализован любым pipe. Общий интерфейс использует T для указания типа входного value и R для указания возвращаемого типа метода transform().

Каждый pipe должнен реализовать метод transform() для выполнения контракта интерфейса PipeTransform. Этот метод имеет два параметра:

  • value
  • metadata

Параметр value - это текущий обрабатываемый аргумент метода (до его получения методом обработки маршрута), а metadata - это метаданные текущего обрабатываемого аргумента метода. Объект метаданных имеет следующие свойства:

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}

Эти свойства описывают текущий обрабатываемый аргумент.

typeУказывает, является ли аргумент телом запроса @Body(), get параметром @Query(), параметром маршрута @Param(), или пользовательским параметром.
metatypeУказывает метатип аргумента, например String. Примечание: значение будет равно undefined если вы либо опустите объявление типа в сигнатуре метода обработчика маршрута, либо используете ванильный JavaScript.
dataСтрока, передаваемая декоратору, например @Body('string'). Значение будет равно undefined если оставить скобки декоратора пустыми.

Интерфейсы TypeScript исчезают при транспиляции. Таким образом, если тип параметра метода объявлен как интерфейс, а не как класс, значение metatype будет Object.

Валидация на основе схемы

Давайте сделаем наш pipe валидации немного более полезным. Рассмотрим подробнее метод create() контроллера CatsController, где мы хотим убедиться что объект post body является валидным, прежде чем пытаться запустить наш метод сервиса CatsService.

@Post()
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

Давайте сосредоточимся на параметре body createCatDto. Его тип - CreateCatDto:

create-cat.dto.ts

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

Мы хотим убедиться, что любой входящий запрос к методу create содержит валидный body. Поэтому мы должны проверить три параметра объекта createCatDto. Мы могли бы сделать это внутри метода обработчика маршрута, но такой подход не идеален, поскольку он нарушит правило одной ответственности (single responsibility rule - SRP).

Другой подход может заключаться в создании класса validator и делегировании задачи туда. Это так же не идеально, т.к. нам придется помнить о вызове этого валидатора в начале каждого метода.

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

Это, конечно, именно тот случай использования, для которого предназначены pipes. Итак, давайте продолжим и доработаем наш pipe валидации.

Валидация схемы объекта

Существует несколько подходов к проверке объектов чистым, DRY способом. Одним из распространенных подходов является использование проверки на основе схемы. Давайте попробуем применить этот подход.

Библиотека Joi позволяет создавать схемы простым способом, с читаемым API. Давайте построим pipe валидации, который использует схемы на основе Joi.

Начнем с установки необходимого пакета:

$ npm install --save joi
$ npm install --save-dev @types/joi

В приведенном ниже примере кода мы создаем простой класс, который принимает схему в качестве аргумента constructor. Затем мы применяем метод schema.validate(), который проверяет наш входящий аргумент на соответствие предоставленной схеме.

Как было отмечено ранее, pipe валидации либо возвращает значение без изменений, либо выбрасывает исключение.

В следующем разделе вы увидите, как мы предоставляем соответствующую схему для данного метода контроллера с помощью декоратора @UsePipes(). Это делает наш pipe валидации пригодным для повторного использования в разных контекстах, как мы и собирались сделать.

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ObjectSchema } from 'joi';
@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private schema: ObjectSchema) {}
  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = this.schema.validate(value);
    if (error) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
}

Привязка pipe валидации

Ранее мы рассмотрели, как связывать pipe преобразования (например, ParseIntPipe и остальные pipe семейства Parse*).

Привязка pipe валидации также очень проста.

В этом случае мы хотим привязать pipe на уровне вызова метода. В нашем примере для использования JoiValidationPipe нам нужно сделать следующее:

  1. Создать экземпляр JoiValidationPipe.
  2. Передать контекстно-специфическую схему Joi в конструктор класса pipe.
  3. Привязать pipe к методу

Мы делаем это с помощью декоратора @UsePipes(), как показано ниже:

@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

Декоратор @UsePipes() импортируется из пакета @nestjs/common.

Валидатор классов

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

Nest хорошо работает с библиотекой class-validator. Эта мощная библиотека позволяет использовать валидацию на основе декораторов. Валидация на основе декораторов является чрезвычайно мощной, особенно в сочетании с возможностями Pipe в Nest, поскольку мы имеем доступ к metatype обрабатываемого свойства. Прежде чем мы начнем, нам нужно установить необходимые пакеты:

$ npm i --save class-validator class-transformer

После их установки мы можем добавить несколько декораторов к классу CreateCatDto. Здесь мы видим значительное преимущество этой техники: класс CreateCatDto остается единственным источником истины для нашего объекта Post body (вместо того, чтобы создавать отдельный класс валидации).

create-cat.dto.ts

import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
  @IsString()
  name: string;
  @IsInt()
  age: number;
  @IsString()
  breed: string;
}

Теперь мы можем создать класс ValidationPipe, который использует эти аннотации.

validation.pipe.ts

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

Выше мы использовали библиотеку class-transformer. Она создана тем же автором, что и библиотека class-validator, и в результате они очень хорошо работают вместе.

Давайте пройдемся по этому коду. Во-первых, обратите внимание, что метод transform() помечен как async. Это возможно потому, что Nest поддерживает как синхронные, так и асинхронные pipe. Мы сделали этот метод async, потому что некоторые проверки класса-валидатора могут быть async (используют Promises).

Далее обратите внимание, что мы используем деструктуризацию для извлечения поля metatype (извлечение только этого члена из ArgumentMetadata) в наш параметр metatype. Это просто сокращение для получения полных ArgumentMetadata и последующего дополнительного оператора для присвоения переменной metatype.

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

Далее мы используем функцию class-transformer plainToClass() для преобразования нашего обычного объекта аргумента JavaScript в типизированный объект, чтобы мы могли применить валидацию. Причина, по которой мы должны это сделать, заключается в том, что входящий объект post body, когда он десериализован из сетевого запроса, не имеет никакой информации о типе (так работает базовая платформа, такая как Express). Class-validator должен использовать декораторы проверки, которые мы определили для нашего DTO ранее, поэтому нам нужно выполнить это преобразование, чтобы рассматривать входящее тело запроса как соответствующим образом оформленный объект, а не просто обычный объект.

Наконец, как было отмечено ранее, поскольку это pipe валидации, он либо возвращает значение без изменений, либо выбрасывает исключение.

Последним шагом является привязка ValidationPipe. Pipes могут быть с привязкой к параметрам, методам, контроллерам или глобальной привязкой. Ранее, на примере pipe валидации на основе Joi, мы видели пример привязки pipe на уровне метода. В примере ниже мы привяжем экземпляр pipe к декоратору обработчика маршрута @Body(), чтобы наш pipe вызывался для проверки тела запроса.

cats.controller.ts

@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

Pipes привязанные к параметрам полезны, когда логика валидации касается только одного заданного параметра.

Глобальные pipes

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

main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

В случае гибридных приложений метод useGlobalPipes() не устанавливает pipe для шлюзов и микросервисов. Для "стандартных" (негибридных) микросервисных приложений метод useGlobalPipes() действительно монтирует pipe глобально.

Глобальные pipe используются во всем приложении, для каждого контроллера и каждого обработчика маршрутов.

Обратите внимание, что с точки зрения инъекции зависимостей глобальные pipe, зарегистрированные вне какого-либо модуля (с useGlobalPipes(), как в примере выше), не могут инъектировать зависимости, поскольку привязка была выполнена вне контекста какого-либо модуля. Чтобы решить эту проблему, вы можете установить глобальный pipe непосредственно из любого модуля, используя следующую конструкцию:

app.module.ts

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

При использовании этого подхода для выполнения инъекции зависимостей для pipe, обратите внимание, что независимо от модуля, в котором используется эта конструкция, pipe, по сути, является глобальным. Где это должно быть сделано? Выберите модуль, в котором определен pipe (ValidationPipe в примере выше). Кроме того, useClass - не единственный способ работы с регистрацией пользовательских провайдеров.

Встроенный ValidationPipe

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

Использование преобразования

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

Когда это полезно? Учтите, что иногда данные, переданные от клиента, должны быть изменены - например, строка преобразуется в целое число - прежде чем они будут правильно обработаны методом обработчика маршрута. Кроме того, некоторые необходимые поля данных могут отсутствовать, и мы хотели бы применить значения по умолчанию. Pipe трансформации могут выполнять эти функции, вставляя функцию обработки между запросом клиента и обработчиком запроса.

Вот простой ParseIntPipe, который отвечает за разбор строки в целочисленное значение. (Как отмечалось выше, в Nest есть встроенный ParseIntPipe, который является более сложным; мы включили его в качестве простого примера пользовательского pipe трансформации).

parse-int.pipe.ts

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

Затем мы можем привязать этот pipe к выбранному параметру, как показано ниже:

@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return this.catsService.findOne(id);
}

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

@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}

Мы оставляем реализацию этого pipe читателю, но отметим, что, как и все другие каналы преобразования, он принимает входное значение (id) и возвращает выходное значение (объект UserEntity). Это может сделать ваш код более декларативным и DRY, абстрагируя код запроса к БД с пользователями из вашего обработчика в общий pipe.

Предоставление значений по умолчанию

Pipes семейства Parse* ожидают, что значение параметра будет определено. При получении значений null или undefined они выбрасывают исключение. Чтобы позволить конечной точке обрабатывать отсутствующие значения параметров, мы должны предоставить значение по умолчанию, которое будет вводиться до того, как трубы Parse* начнут работать с этими значениями. Для этой цели служит pipe DefaultValuePipe. Просто инстанцируйте DefaultValuePipe в декораторе @Query() перед соответствующим pipe Parse*, как показано ниже:

@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}