Пошук уроків, статей та іншого контенту
Реалізуєте власні pipes для складної валідації, нормалізації та перетворення параметрів і тіла запиту.
Pipe у NestJS — це клас, який отримує значення аргументу обробника маршруту та може:
перевірити його коректність;
нормалізувати формат;
перетворити тип;
відхилити запит через виняток.
Кожен pipe реалізує інтерфейс PipeTransform:
interface PipeTransform<T = any, R = any> {
transform(value: T, metadata: ArgumentMetadata): R;
}Метод transform() викликається перед виконанням методу контролера. Якщо він повертає значення, контролер отримує саме це значення. Якщо pipe викидає виняток, NestJS завершує обробку запиту та повертає помилку клієнту.
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
@Injectable()
export class CustomPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata) {
// Валідація, нормалізація або перетворення value
return value;
}
}ArgumentMetadata містить інформацію про аргумент:
{
type: 'param' | 'query' | 'body' | 'custom',
data?: string,
metatype?: Type<unknown>
}Наприклад:
type показує джерело значення;
data містить назву параметра (id, `email тощо);
metatype містить очікуваний клас або тип.
Параметри маршруту надходять до NestJS як рядки. Навіть якщо клієнт передає /users/42, значення id спочатку буде рядком "42".
Створимо pipe, який:
приймає лише рядок або число;
перетворює значення на число;
перевіряє, що це додатне ціле число;
повертає вже перетворене число.
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
@Injectable()
export class PositiveIntPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata): number {
if (metadata.type !== 'param') {
throw new BadRequestException(
'PositiveIntPipe можна використовувати лише для параметрів маршруту',
);
}
if (typeof value !== 'string' && typeof value !== 'number') {
throw new BadRequestException('Значення має бути числом');
}
const stringValue = String(value).trim();
if (stringValue.length === 0) {
throw new BadRequestException('Значення не може бути порожнім');
}
const parsedValue = Number(stringValue);
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
throw new BadRequestException(
'Значення має бути додатним цілим числом',
);
}
return parsedValue;
}
}Pipe можна застосувати лише до одного параметра:
import { Controller, Get, Param } from '@nestjs/common';
import { PositiveIntPipe } from './positive-int.pipe';
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id', new PositiveIntPipe()) id: number) {
return {
id,
message: `Користувача з ідентифікатором ${id} знайдено`,
};
}
}Для запиту:
GET /users/42метод контролера отримає:
id === 42А запити /users/0, /users/-1 та /users/abc завершаться помилкою 400 Bad Request.
Для тіла запиту pipe може одночасно:
перевірити структуру об'єкта;
перевірити типи властивостей;
нормалізувати рядки;
привести email до нижнього регістру;
видалити зайві властивості;
повернути об'єкт у безпечному форматі.
Розглянемо pipe для створення профілю користувача.
Очікуване тіло запиту:
{
"name": " Olena Kovalenko ",
"email": " OLENA@EXAMPLE.COM ",
"age": 29,
"roles": ["user", "editor", "user"]
}Після обробки контролер має отримати:
{
"name": "Olena Kovalenko",
"email": "olena@example.com",
"age": 29,
"roles": ["user", "editor"]
}Реалізація pipe:
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
export interface UserProfile {
name: string;
email: string;
age: number;
roles: string[];
}
@Injectable()
export class UserProfilePipe implements PipeTransform {
private readonly allowedRoles = new Set(['user', 'editor', 'admin']);
transform(value: unknown, metadata: ArgumentMetadata): UserProfile {
if (metadata.type !== 'body') {
throw new BadRequestException(
'UserProfilePipe можна використовувати лише для body',
);
}
if (
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
throw new BadRequestException(
'Тіло запиту має бути звичайним об’єктом',
);
}
const input = value as Record<string, unknown>;
const name = this.normalizeName(input.name);
const email = this.normalizeEmail(input.email);
const age = this.validateAge(input.age);
const roles = this.normalizeRoles(input.roles);
return {
name,
email,
age,
roles,
};
}
private normalizeName(value: unknown): string {
if (typeof value !== 'string') {
throw new BadRequestException('Поле name має бути рядком');
}
const name = value.trim().replace(/\s+/g, ' ');
if (name.length < 2 || name.length > 100) {
throw new BadRequestException(
'Поле name має містити від 2 до 100 символів',
);
}
return name;
}
private normalizeEmail(value: unknown): string {
if (typeof value !== 'string') {
throw new BadRequestException('Поле email має бути рядком');
}
const email = value.trim().toLowerCase();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
throw new BadRequestException('Поле email має некоректний формат');
}
return email;
}
private validateAge(value: unknown): number {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 18 ||
value > 120
) {
throw new BadRequestException(
'Поле age має бути цілим числом від 18 до 120',
);
}
return value;
}
private normalizeRoles(value: unknown): string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new BadRequestException('Поле roles має бути масивом');
}
const normalizedRoles = value.map((role) => {
if (typeof role !== 'string') {
throw new BadRequestException(
'Кожна роль у roles має бути рядком',
);
}
const normalizedRole = role.trim().toLowerCase();
if (!this.allowedRoles.has(normalizedRole)) {
throw new BadRequestException(
`Непідтримувана роль: ${normalizedRole}`,
);
}
return normalizedRole;
});
return [...new Set(normalizedRoles)];
}
}У цьому прикладі pipe повертає новий об'єкт, а не змінює вхідне значення. Це робить поведінку передбачуванішою та не допускає випадкового використання зайвих властивостей.
Нижче наведено мінімальний застосунок, у якому обидва custom pipes використовуються в контролері.
import {
ArgumentMetadata,
BadRequestException,
Body,
Controller,
Get,
Injectable,
Module,
Param,
PipeTransform,
Post,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
export interface UserProfile {
name: string;
email: string;
age: number;
roles: string[];
}
@Injectable()
class PositiveIntPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata): number {
if (metadata.type !== 'param') {
throw new BadRequestException(
'PositiveIntPipe можна використовувати лише для параметрів маршруту',
);
}
if (typeof value !== 'string' && typeof value !== 'number') {
throw new BadRequestException('Значення має бути числом');
}
const stringValue = String(value).trim();
if (stringValue.length === 0) {
throw new BadRequestException('Значення не може бути порожнім');
}
const parsedValue = Number(stringValue);
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
throw new BadRequestException(
'Значення має бути додатним цілим числом',
);
}
return parsedValue;
}
}
@Injectable()
class UserProfilePipe implements PipeTransform {
private readonly allowedRoles = new Set(['user', 'editor', 'admin']);
transform(value: unknown, metadata: ArgumentMetadata): UserProfile {
if (metadata.type !== 'body') {
throw new BadRequestException(
'UserProfilePipe можна використовувати лише для body',
);
}
if (
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
throw new BadRequestException(
'Тіло запиту має бути звичайним об’єктом',
);
}
const input = value as Record<string, unknown>;
return {
name: this.normalizeName(input.name),
email: this.normalizeEmail(input.email),
age: this.validateAge(input.age),
roles: this.normalizeRoles(input.roles),
};
}
private normalizeName(value: unknown): string {
if (typeof value !== 'string') {
throw new BadRequestException('Поле name має бути рядком');
}
const name = value.trim().replace(/\s+/g, ' ');
if (name.length < 2 || name.length > 100) {
throw new BadRequestException(
'Поле name має містити від 2 до 100 символів',
);
}
return name;
}
private normalizeEmail(value: unknown): string {
if (typeof value !== 'string') {
throw new BadRequestException('Поле email має бути рядком');
}
const email = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException('Поле email має некоректний формат');
}
return email;
}
private validateAge(value: unknown): number {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 18 ||
value > 120
) {
throw new BadRequestException(
'Поле age має бути цілим числом від 18 до 120',
);
}
return value;
}
private normalizeRoles(value: unknown): string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new BadRequestException('Поле roles має бути масивом');
}
const roles = value.map((role) => {
if (typeof role !== 'string') {
throw new BadRequestException(
'Кожна роль у roles має бути рядком',
);
}
const normalizedRole = role.trim().toLowerCase();
if (!this.allowedRoles.has(normalizedRole)) {
throw new BadRequestException(
`Непідтримувана роль: ${normalizedRole}`,
);
}
return normalizedRole;
});
return [...new Set(roles)];
}
}
@Controller('users')
class UsersController {
@Get(':id')
findOne(@Param('id', new PositiveIntPipe()) id: number) {
return {
id,
message: `Користувача з ідентифікатором ${id} знайдено`,
};
}
@Post()
create(@Body(new UserProfilePipe()) profile: UserProfile) {
return {
message: 'Профіль успішно нормалізовано',
profile,
};
}
}
@Module({
controllers: [UsersController],
})
class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Для перевірки можна надіслати запит:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{
"name": " Olena Kovalenko ",
"email": " OLENA@EXAMPLE.COM ",
"age": 29,
"roles": ["user", "editor", "user"],
"isAdmin": true
}'Властивість isAdmin не потрапить до результату, оскільки pipe явно формує об'єкт лише з дозволених полів.
Pipe можна прив'язати до конкретного аргументу:
@Get(':id')
findOne(@Param('id', new PositiveIntPipe()) id: number) {
return { id };
}До всіх параметрів одного методу:
@Post()
@UsePipes(new UserProfilePipe())
create(@Body() profile: UserProfile) {
return profile;
}До всіх методів контролера:
@Controller('users')
@UsePipes(new UserProfilePipe())
export class UsersController {}Глобальна реєстрація підходить лише для pipe, який може коректно обробляти різні типи аргументів:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new SomeGlobalPipe());
await app.listen(3000);
}Pipe для конкретної структури тіла запиту не варто реєструвати глобально. Інакше він спробує обробити body кожного маршруту, включно з маршрутами, для яких потрібна інша структура.
Якщо pipe має залежності, не слід створювати його через new, оскільки NestJS не зможе виконати ін'єкцію залежностей:
// Залежності не будуть автоматично ін'єктовані
@Body(new UserProfilePipe())У такому випадку pipe потрібно зареєструвати як provider і передати NestJS сам клас:
import { Module } from '@nestjs/common';
@Module({
controllers: [UsersController],
providers: [UserProfilePipe],
})
export class AppModule {}Після цього pipe можна використовувати як клас:
@Post()
create(@Body(UserProfilePipe) profile: UserProfile) {
return profile;
}Приклад pipe із залежністю:
import { Injectable, PipeTransform } from '@nestjs/common';
import { UsersService } from './users.service';
@Injectable()
export class UniqueEmailPipe implements PipeTransform {
constructor(private readonly usersService: UsersService) {}
async transform(value: unknown) {
if (typeof value !== 'object' || value === null) {
return value;
}
const body = value as Record<string, unknown>;
if (typeof body.email !== 'string') {
return value;
}
const exists = await this.usersService.existsByEmail(body.email);
if (exists) {
throw new BadRequestException(
'Користувач із таким email уже існує',
);
}
return value;
}
}transform() може бути асинхронним і повертати Promise. NestJS дочекається результату перед викликом контролера.
Такий pipe потрібно використовувати обережно: перевірка унікальності зазвичай залежить від бази даних і має бути частиною чітко визначеного потоку валідації. Pipe підходить, якщо перевірка безпосередньо стосується прийняття або перетворення вхідного значення.
Для помилок валідації зазвичай використовують BadRequestException:
throw new BadRequestException('Некоректне значення');Можна повернути структуровану відповідь:
throw new BadRequestException({
message: 'Помилка валідації',
field: 'email',
reason: 'Некоректний формат',
});Структурований формат зручний для клієнта, оскільки він може визначити конкретне поле та показати відповідне повідомлення користувачу.
Pipe не повинен повертати false, null або спеціальний об'єкт помилки замість викидання винятку. NestJS не сприйматиме таке значення як помилку автоматично — воно потрапить до контролера як звичайний результат transform().
Pipe має виконувати операції, безпосередньо пов'язані з вхідним значенням:
перевірка типу;
перевірка формату;
нормалізація;
перетворення;
перевірка локальної умови.
Не варто розміщувати в одному pipe:
бізнес-операції;
створення записів у базі даних;
надсилання повідомлень;
складні транзакції;
логіку, що не пов'язана з підготовкою вхідних даних.
Чим більше відповідальності має pipe, тим складніше повторно використовувати та тестувати його. Для складної перевірки краще розділити операції на декілька pipe або передати бізнес-логіку сервісу після завершення валідації.
Небажано без потреби змінювати value:
value.email = value.email.trim().toLowerCase();
return value;Краще створювати новий об'єкт:
return {
...value,
email: value.email.trim().toLowerCase(),
};Це зменшує ризик побічних ефектів і дозволяє явно контролювати поля, які потрапляють далі.
Тип параметра в контролері не перевіряє дані під час виконання:
create(@Body() profile: UserProfile) {
return profile;
}Клієнт може надіслати будь-який JSON. Перевірку потрібно виконати під час виконання — у pipe або іншому механізмі валідації.
Такий код некоректний:
if (!value.age) {
throw new BadRequestException('Вік обов’язковий');
}Він змішує відсутність значення з іншими значеннями та не перевіряє тип. Краще перевіряти умови явно:
if (
typeof value.age !== 'number' ||
!Number.isInteger(value.age)
) {
throw new BadRequestException('Вік має бути цілим числом');
}Якщо pipe повертає весь вхідний об'єкт, клієнт може передати поля, які не передбачені API:
return value;Для структурованого body краще явно сформувати результат:
return {
name,
email,
age,
roles,
};Pipe, який очікує об'єкт body, не повинен застосовуватися до параметра маршруту. За потреби перевіряйте metadata.type та прив'язуйте pipe до конкретного аргументу.
Custom pipe реалізує PipeTransform і визначає метод transform().
Pipe може перевіряти, нормалізувати та перетворювати значення до передачі в контролер.
ArgumentMetadata допомагає визначити джерело та контекст вхідного значення.
Для помилок валідації потрібно викидати BadRequestException або інший відповідний HTTP-виняток.
Для body безпечніше повертати новий об'єкт лише з дозволеними та нормалізованими полями.
Асинхронний transform() можна використовувати для перевірок, що потребують залежностей або зовнішніх даних.
Pipe із залежностями потрібно реєструвати як provider і передавати NestJS клас pipe, а не створювати його через new.
Pipe має залишатися сфокусованим на підготовці та перевірці вхідних даних.