Тема 7. Фреймворк NestJS. Контролери та маршрутизація

Тіло запиту та декоратор @Body

Витягування даних з тіла POST/PUT/PATCH запитів

Тіло запиту та декоратор @Body

🎯 Мета лекції

  • Зрозуміти призначення тіла запиту у HTTP-протоколі та його відмінність від параметрів URL
  • Опанувати використання декоратора @Body для витягування даних з тіла запиту
  • Навчитися створювати DTO для структурованої типізації вхідних даних
  • Вивчити автоматичну валідацію тіла запиту через class-validator
  • Практикувати створення та оновлення ресурсів через POST, PUT та PATCH
  • Зрозуміти різницю між повною заміною (PUT) та частковим оновленням (PATCH)
  • Застосовувати трансформацію та санітизацію вхідних даних

🔑 Ключові терміни

  • Request Body (тіло запиту): дані, що передаються у HTTP-запиті окремо від URL
  • DTO (Data Transfer Object): клас, що описує структуру даних для передачі між шарами
  • Validation (валідація): перевірка коректності даних згідно з визначеними правилами
  • Sanitization (санітизація): очищення та нормалізація вхідних даних
  • Content-Type: HTTP-заголовок, що вказує формат даних у тілі запиту
  • Idempotency (ідемпотентність): властивість операції давати однаковий результат при повторах

Тіло запиту: передача структурованих даних

У попередніх лекціях ми вивчили два способи передачі даних у HTTP-запитах: параметри маршруту (:id) для ідентифікації ресурсів та query-параметри (?page=1) для фільтрації та опцій. Проте обидва ці методи мають суттєве обмеження — вони передають дані як частину URL, що робить їх непридатними для великих або складних структур даних.

Тіло запиту (request body) вирішує цю проблему, дозволяючи передавати довільні обсяги структурованих даних окремо від URL. Тіло запиту використовується переважно у методах, що змінюють стан сервера: POST (створення), PUT (повна заміна), PATCH (часткове оновлення).

Анатомія HTTP-запиту з тілом

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "HTTP POST Запит" {
    rectangle "Request Line" as RL #E2E8F0 {
        note "POST /api/users HTTP/1.1" as RL_note
    }
    
    rectangle "Headers (Заголовки)" as H #DBEAFE {
        rectangle "Host: api.example.com" as H1 #F1F5F9
        rectangle "Content-Type: application/json" as H2 #FEF3C7
        rectangle "Content-Length: 85" as H3 #F1F5F9
        rectangle "Authorization: Bearer token..." as H4 #F1F5F9
    }
    
    rectangle "Empty Line" as EL #E2E8F0
    
    rectangle "Body (Тіло запиту)" as B #DCFCE7 {
        note left
{
  "name": "John Doe",
  "email": "john@example.com",
  "age": 30,
  "role": "user"
}
        end note
    }
}

note bottom of H2
  Content-Type вказує формат даних.
  application/json — найпоширеніший для API.
end note

note bottom of B
  Тіло містить структуровані дані,
  недоступні через URL (великий обсяг,
  вкладені об'єкти, масиви).
end note

@enduml

Коли використовувати тіло запиту

Використовуйте тіло запиту для:

Створення ресурсів (POST):

POST /users
Body: { "name": "Alice", "email": "alice@example.com", "password": "..." }

Повного оновлення (PUT):

PUT /users/123
Body: { "name": "Alice Smith", "email": "alice@example.com", "age": 28, "role": "admin" }

Часткового оновлення (PATCH):

PATCH /users/123
Body: { "email": "newemail@example.com" }

Складних операцій:

POST /orders/bulk
Body: [
  { "productId": 1, "quantity": 2 },
  { "productId": 5, "quantity": 1 }
]

НЕ використовуйте тіло запиту для:

  • GET-запитів (за HTTP-специфікацією GET не повинен мати тіла)
  • DELETE-запитів (зазвичай використовують параметри URL)
  • Маленьких простих даних, що можуть бути передані через query-параметри
Хоча технічно HTTP дозволяє GET-запити з тілом, більшість інфраструктури (проксі-сервери, кеші, балансувальники навантаження) ігнорують або відкидають тіла у GET-запитах. Це вважається поганою практикою та порушує семантику протоколу.

Content-Type: формати тіла запиту

HTTP-заголовок Content-Type вказує, у якому форматі закодовані дані у тілі запиту. Найпоширеніші типи:

Loading diagram...
graph TB
    subgraph "Content-Type Headers"
        JSON["application/json<br/>{ key: value }"]
        FORM["application/x-www-form-urlencoded<br/>key1=value1&key2=value2"]
        MULTIPART["multipart/form-data<br/>Файли + дані"]
        XML["application/xml<br/>&lt;root&gt;&lt;key&gt;value&lt;/key&gt;&lt;/root&gt;"]
        TEXT["text/plain<br/>Raw text"]
    end
    
    JSON --> |"Найпопулярніший<br/>для RESTful API"| POPULAR
    FORM --> |"HTML форми<br/>без файлів"| LEGACY
    MULTIPART --> |"Завантаження<br/>файлів"| FILES
    
    style JSON fill:#22c55e,stroke:#15803d,color:#ffffff
    style FORM fill:#f59e0b,stroke:#b45309,color:#ffffff
    style MULTIPART fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style XML fill:#64748b,stroke:#334155,color:#ffffff
    style TEXT fill:#64748b,stroke:#334155,color:#ffffff

application/json — стандарт для сучасних API:

{
  "name": "Product",
  "price": 99.99,
  "tags": ["electronics", "gadgets"]
}

application/x-www-form-urlencoded — традиційні HTML-форми:

name=Product&price=99.99&tags=electronics&tags=gadgets

multipart/form-data — завантаження файлів (розглянемо у наступних лекціях):

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"

Product
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="image.jpg"
Content-Type: image/jpeg

[binary data]
У сучасних RESTful API майже завжди використовується application/json. NestJS автоматично парсить JSON-тіло завдяки вбудованому Express/Fastify middleware, тому вам не потрібно виконувати ручне перетворення.

Декоратор @Body: витягування даних з тіла запиту

NestJS надає декоратор @Body() для доступу до даних, переданих у тілі HTTP-запиту. Подібно до @Param() та @Query(), цей декоратор має два режими: витягування всього тіла або окремого поля.

Режим 1: Витягування всього тіла (@Body())

Найпоширеніший сценарій — витягування всього об'єкта з тіла запиту:

import { Controller, Post, Body } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Post()
  create(@Body() body: any) {
    console.log(body);
    // POST /users з тілом { "name": "Alice", "email": "alice@example.com" }
    // → body = { name: "Alice", email: "alice@example.com" }
    
    return {
      message: 'User created',
      data: body,
    };
  }
}
Використання типу any для тіла запиту є поганою практикою. Це втрачає всі переваги TypeScript (автодоповнення, перевірка типів) та робить код вразливим до помилок. Завжди типізуйте тіло запиту через DTO.

Режим 2: Витягування окремого поля (@Body('key'))

Іноді потрібно отримати лише одне поле з тіла:

@Post()
create(
  @Body('name') name: string,
  @Body('email') email: string,
) {
  return {
    message: `Creating user: ${name} (${email})`,
  };
}

Проте цей підхід стає незручним при багатьох полях. Рекомендується використовувати DTO для витягування всього об'єкта з правильною типізацією.

DTO: Data Transfer Objects для типізації

Data Transfer Object (DTO) — це клас, що описує структуру даних, які передаються між різними шарами застосунку. У контексті NestJS контролерів DTO використовуються для:

  1. Типізації вхідних даних (TypeScript автодоповнення та перевірка типів)
  2. Валідації даних через декоратори class-validator
  3. Документування очікуваної структури даних
  4. Трансформації даних через class-transformer

Створення базового DTO

Розглянемо DTO для створення користувача:

// dto/create-user.dto.ts
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
  age?: number; // Опціональне поле
  role?: string;
}

Використання у контролері:

import { Controller, Post, Body } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    // TypeScript знає про всі поля DTO
    console.log(createUserDto.name);    // ✅ Автодоповнення
    console.log(createUserDto.email);   // ✅ Автодоповнення
    console.log(createUserDto.unknown); // ❌ Помилка компіляції
    
    return this.usersService.create(createUserDto);
  }
}

Валідація DTO через class-validator

Базовий DTO забезпечує типізацію під час компіляції, але не виконує runtime-валідацію. Для автоматичної перевірки коректності даних використовуйте бібліотеку class-validator:

npm install class-validator class-transformer

DTO з валідацією:

// dto/create-user.dto.ts
import {
  IsString,
  IsEmail,
  IsNotEmpty,
  IsOptional,
  IsInt,
  Min,
  Max,
  MinLength,
  MaxLength,
  Matches,
  IsIn,
} from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty({ message: 'Name is required' })
  @MinLength(2, { message: 'Name must be at least 2 characters' })
  @MaxLength(50, { message: 'Name must not exceed 50 characters' })
  name: string;

  @IsEmail({}, { message: 'Invalid email format' })
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password must contain uppercase, lowercase and number',
  })
  password: string;

  @IsOptional()
  @IsInt()
  @Min(13)
  @Max(120)
  age?: number;

  @IsOptional()
  @IsIn(['user', 'admin', 'moderator'])
  role?: string;
}

Увімкнення глобальної валідації у main.ts:

// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,           // Автоматична трансформація типів
      whitelist: true,           // Видалення полів, не описаних у DTO
      forbidNonWhitelisted: true, // Помилка при невідомих полях
      transformOptions: {
        enableImplicitConversion: true, // Неявне перетворення типів
      },
    }),
  );

  await app.listen(3000);
}
bootstrap();

Тепер валідація працює автоматично:

Автоматична валідація тіла запиту
# Коректний запит
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","password":"SecurePass123"}'
HTTP/1.1 201 Created
{ "id": 1, "name": "Alice", ... }
# Відсутнє обов'язкове поле
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice"}'
HTTP/1.1 400 Bad Request
{
"message": [
"email must be an email",
"email should not be empty",
"password must be at least 8 characters"
],
"error": "Bad Request"
}
# Некоректний формат email
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"not-an-email","password":"SecurePass123"}'
HTTP/1.1 400 Bad Request
{
"message": ["Invalid email format"]
}
# Невідоме поле (при forbidNonWhitelisted: true)
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@ex.com","password":"Pass123","hacker":"field"}'
HTTP/1.1 400 Bad Request
{
"message": ["property hacker should not exist"]
}

Популярні декоратори class-validator

import {
  IsString,
  IsNotEmpty,
  MinLength,
  MaxLength,
  Matches,
  Contains,
  IsAlpha,
  IsAlphanumeric,
} from 'class-validator';

export class StringValidationDto {
  @IsString()
  @IsNotEmpty()
  name: string;

  @MinLength(5)
  @MaxLength(20)
  username: string;

  @Matches(/^[a-zA-Z0-9-_]+$/)
  slug: string;

  @Contains('hello')
  greeting: string;

  @IsAlpha() // Тільки літери
  firstName: string;

  @IsAlphanumeric() // Літери та цифри
  code: string;
}
Використовуйте кастомні повідомлення про помилки для покращення UX:
@MinLength(8, { message: 'Пароль має містити щонайменше 8 символів' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
  message: 'Пароль має містити великі та малі літери, а також цифри',
})
password: string;

POST: створення нових ресурсів

HTTP-метод POST призначений для створення нових ресурсів. Клієнт надсилає дані у тілі запиту, сервер створює новий запис та повертає інформацію про створений ресурс, зазвичай зі статусом 201 Created.

Базовий приклад створення користувача

// dto/create-user.dto.ts
import { IsString, IsEmail, MinLength, IsOptional, IsIn } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsOptional()
  @IsIn(['user', 'admin'])
  role?: string;
}

Контролер:

// users.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    const user = await this.usersService.create(createUserDto);
    
    // NestJS автоматично встановлює статус 201 Created для POST
    return {
      message: 'User created successfully',
      data: user,
    };
  }
}

Сервіс:

// users.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    // Перевірка унікальності email
    const existingUser = await this.userRepository.findOne({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('User with this email already exists');
    }

    // Хешування пароля
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

    // Створення нового користувача
    const user = this.userRepository.create({
      ...createUserDto,
      password: hashedPassword,
      role: createUserDto.role || 'user', // Значення за замовчуванням
    });

    // Збереження у базі даних
    const savedUser = await this.userRepository.save(user);

    // Видалення пароля з відповіді
    const { password, ...result } = savedUser;
    return result as User;
  }
}

Приклад використання:

POST /users - Створення користувача
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Johnson",
"email": "alice@example.com",
"password": "SecurePass123"
}'
HTTP/1.1 201 Created
Location: /users/1
{
"message": "User created successfully",
"data": {
"id": 1,
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "user",
"createdAt": "2026-09-04T10:30:00.000Z"
}
}
Статус 201 Created автоматично встановлюється NestJS для POST-запитів. Рекомендується також додавати заголовок Location з URL новоствореного ресурсу:
@Post()
async create(@Body() dto: CreateUserDto, @Res({ passthrough: true }) res: Response) {
  const user = await this.usersService.create(dto);
  res.header('Location', `/users/${user.id}`);
  return user;
}

PUT: повна заміна ресурсу

HTTP-метод PUT призначений для повної заміни існуючого ресурсу. Клієнт надсилає всі дані ресурсу, і сервер замінює поточний стан повністю новими даними. PUT є ідемпотентним — повторне виконання з тими самими даними дає той самий результат.

DTO для оновлення

// dto/update-user.dto.ts
import { IsString, IsEmail, MinLength, IsOptional, IsInt, Min, IsIn } from 'class-validator';

export class UpdateUserDto {
  @IsString()
  @MinLength(2)
  name: string; // Обов'язкове — PUT замінює весь ресурс

  @IsEmail()
  email: string; // Обов'язкове

  @IsOptional()
  @IsString()
  @MinLength(8)
  password?: string; // Опціональне — може не змінюватися

  @IsOptional()
  @IsInt()
  @Min(13)
  age?: number;

  @IsOptional()
  @IsIn(['user', 'admin', 'moderator'])
  role?: string;
}

Контролер:

import { Controller, Put, Param, Body, ParseIntPipe, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Put(':id')
  async replace(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    const user = await this.usersService.replace(id, updateUserDto);
    
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    
    return {
      message: 'User replaced successfully',
      data: user,
    };
  }
}

Сервіс з повною заміною:

// users.service.ts
async replace(id: number, updateUserDto: UpdateUserDto): Promise<User> {
  // Перевірка існування
  const user = await this.userRepository.findOne({ where: { id } });
  
  if (!user) {
    throw new NotFoundException(`User with ID ${id} not found`);
  }

  // Хешування нового пароля, якщо він переданий
  if (updateUserDto.password) {
    updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10);
  }

  // Повна заміна: встановлюємо всі поля з DTO
  // Поля, не передані у DTO, мають бути встановлені явно або видалені
  const updatedUser = {
    ...user,
    ...updateUserDto,
    updatedAt: new Date(),
  };

  const result = await this.userRepository.save(updatedUser);
  const { password, ...safeUser } = result;
  return safeUser as User;
}
Семантика PUT вимагає передачі всіх полів ресурсу. Якщо клієнт не передає поле, воно має бути встановлене у значення за замовчуванням або видалене. На практиці багато API порушують цю семантику, використовуючи PUT як PATCH. Для справжньої повної заміни використовуйте PUT, для часткового оновлення — PATCH.

PATCH: часткове оновлення ресурсу

HTTP-метод PATCH призначений для часткового оновлення існуючого ресурсу. Клієнт надсилає лише ті поля, які потрібно змінити, інші залишаються без змін. PATCH є більш гнучким та зручним для клієнтів, ніж PUT.

DTO для часткового оновлення через Partial

TypeScript надає утиліту Partial<T>, що робить всі поля типу опціональними. Це ідеально підходить для PATCH:

// dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types'; // ⚠️ Використовуйте цей, а не TypeScript Partial!
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

// Альтернативно, явне визначення:
// export class UpdateUserDto {
//   @IsOptional()
//   @IsString()
//   @MinLength(2)
//   name?: string;
//
//   @IsOptional()
//   @IsEmail()
//   email?: string;
//
//   @IsOptional()
//   @IsString()
//   @MinLength(8)
//   password?: string;
//
//   @IsOptional()
//   @IsInt()
//   @Min(13)
//   age?: number;
//
//   @IsOptional()
//   @IsIn(['user', 'admin', 'moderator'])
//   role?: string;
// }
@nestjs/mapped-types надає PartialType, який:
  1. Робить всі поля опціональними (як TypeScript Partial<T>)
  2. Зберігає декоратори валідації з базового DTO
  3. Автоматично додає @IsOptional() до всіх полів
Стандартний TypeScript Partial<T>НЕ зберігає декоратори валідації!
npm install @nestjs/mapped-types

Контролер:

import { Controller, Patch, Param, Body, ParseIntPipe } from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Patch(':id')
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    const user = await this.usersService.update(id, updateUserDto);
    
    return {
      message: 'User updated successfully',
      data: user,
    };
  }
}

Сервіс з частковим оновленням:

// users.service.ts
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
  // Перевірка існування
  const user = await this.userRepository.findOne({ where: { id } });
  
  if (!user) {
    throw new NotFoundException(`User with ID ${id} not found`);
  }

  // Хешування пароля, якщо він оновлюється
  if (updateUserDto.password) {
    updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10);
  }

  // Часткове оновлення: застосовуємо лише передані поля
  Object.assign(user, updateUserDto);
  user.updatedAt = new Date();

  const result = await this.userRepository.save(user);
  const { password, ...safeUser } = result;
  return safeUser as User;
}

Приклади використання:

curl -X PATCH http://localhost:3000/users/1 \
  -H "Content-Type: application/json" \
  -d '{ "email": "newemail@example.com" }'

# Результат: змінено лише email, інші поля залишились без змін

PUT vs PATCH: порівняння

Loading diagram...
graph TB
    subgraph "PUT - Повна заміна"
        PUT_REQ["Клієнт надсилає<br/>ВСІ поля"]
        PUT_SRV["Сервер замінює<br/>ресурс повністю"]
        PUT_RES["Незазначені поля<br/>скидаються"]
        
        PUT_REQ --> PUT_SRV
        PUT_SRV --> PUT_RES
        
        style PUT_REQ fill:#f59e0b,stroke:#b45309,color:#ffffff
        style PUT_SRV fill:#f59e0b,stroke:#b45309,color:#ffffff
        style PUT_RES fill:#f59e0b,stroke:#b45309,color:#ffffff
    end
    
    subgraph "PATCH - Часткове оновлення"
        PATCH_REQ["Клієнт надсилає<br/>ТІЛЬКИ змінені поля"]
        PATCH_SRV["Сервер оновлює<br/>лише ці поля"]
        PATCH_RES["Інші поля<br/>залишаються без змін"]
        
        PATCH_REQ --> PATCH_SRV
        PATCH_SRV --> PATCH_RES
        
        style PATCH_REQ fill:#22c55e,stroke:#15803d,color:#ffffff
        style PATCH_SRV fill:#22c55e,stroke:#15803d,color:#ffffff
        style PATCH_RES fill:#22c55e,stroke:#15803d,color:#ffffff
    end
// DTO для PUT
export class ReplaceUserDto {
  @IsString()
  @MinLength(2)
  name: string; // ✅ Обов'язкове

  @IsEmail()
  email: string; // ✅ Обов'язкове

  @IsInt()
  @Min(13)
  age: number; // ✅ Обов'язкове

  @IsIn(['user', 'admin'])
  role: string; // ✅ Обов'язкове
}

// PUT /users/1
// Body: {
//   "name": "Alice",
//   "email": "alice@ex.com",
//   "age": 28,
//   "role": "admin"
// }
// ❌ Якщо не передати age — помилка валідації

Вкладені об'єкти та масиви у DTO

Реальні API часто приймають складні структури з вкладеними об'єктами та масивами. class-validator підтримує валідацію таких структур через декоратори @ValidateNested() та @Type().

Валідація вкладених об'єктів

// dto/address.dto.ts
import { IsString, IsPostalCode, MinLength } from 'class-validator';

export class AddressDto {
  @IsString()
  @MinLength(3)
  street: string;

  @IsString()
  city: string;

  @IsString()
  @IsPostalCode('UA') // Валідація українського поштового індексу
  postalCode: string;

  @IsString()
  country: string;
}
// dto/create-user.dto.ts
import { IsString, IsEmail, ValidateNested, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { AddressDto } from './address.dto';

export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;

  @IsOptional()
  @ValidateNested() // Застосувати валідацію до вкладеного об'єкта
  @Type(() => AddressDto) // Трансформувати plain object у клас AddressDto
  address?: AddressDto;
}

Приклад запиту:

POST /users
{
  "name": "Alice",
  "email": "alice@example.com",
  "address": {
    "street": "Khreshchatyk St, 1",
    "city": "Kyiv",
    "postalCode": "01001",
    "country": "Ukraine"
  }
}

Якщо адреса некоректна, валідація поверне помилку:

{
  "statusCode": 400,
  "message": [
    "address.street must be at least 3 characters",
    "address.postalCode must be a postal code in UA"
  ],
  "error": "Bad Request"
}

Валідація масивів примітивів

// dto/create-article.dto.ts
import { IsString, IsArray, ArrayMinSize, ArrayMaxSize } from 'class-validator';

export class CreateArticleDto {
  @IsString()
  title: string;

  @IsString()
  content: string;

  @IsArray()
  @ArrayMinSize(1, { message: 'At least one tag is required' })
  @ArrayMaxSize(10, { message: 'Maximum 10 tags allowed' })
  @IsString({ each: true }) // Валідація кожного елемента масиву
  tags: string[];
}

Приклад:

POST /articles
{
  "title": "NestJS Tutorial",
  "content": "Learn NestJS...",
  "tags": ["nestjs", "typescript", "backend"]
}

Валідація масивів об'єктів

// dto/order-item.dto.ts
import { IsInt, Min, IsUUID } from 'class-validator';

export class OrderItemDto {
  @IsUUID()
  productId: string;

  @IsInt()
  @Min(1)
  quantity: number;
}
// dto/create-order.dto.ts
import { IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
import { OrderItemDto } from './order-item.dto';

export class CreateOrderDto {
  @IsArray()
  @ArrayMinSize(1, { message: 'Order must contain at least one item' })
  @ValidateNested({ each: true }) // Валідувати кожен елемент масиву
  @Type(() => OrderItemDto)
  items: OrderItemDto[];
}

Приклад замовлення:

POST /orders
{
  "items": [
    { "productId": "123e4567-e89b-12d3-a456-426614174000", "quantity": 2 },
    { "productId": "987f6543-e21c-45d6-a123-987654321000", "quantity": 1 }
  ]
}

Трансформація та санітизація даних

Іноді потрібно не лише валідувати, а й трансформувати або санітизувати вхідні дані перед обробкою. class-transformer надає декоратори для автоматичної трансформації.

Автоматичне обрізання пробілів

import { Transform } from 'class-transformer';
import { IsString, IsEmail } from 'class-validator';

export class CreateUserDto {
  @Transform(({ value }) => value?.trim()) // Видалити пробіли на початку/кінці
  @IsString()
  name: string;

  @Transform(({ value }) => value?.toLowerCase().trim()) // Перевести у нижній регістр
  @IsEmail()
  email: string;
}

Тепер якщо клієнт надішле " Alice ", ім'я буде збережено як "Alice".

Перетворення типів

import { Type, Transform } from 'class-transformer';
import { IsDate, IsBoolean, IsNumber } from 'class-validator';

export class UpdateProductDto {
  @Type(() => Number) // Перетворити рядок "99.99" у число 99.99
  @IsNumber()
  price?: number;

  @Type(() => Date) // Перетворити рядок "2024-01-15" у Date
  @IsDate()
  releaseDate?: Date;

  @Transform(({ value }) => value === 'true' || value === true)
  @IsBoolean()
  isActive?: boolean;
}

Кастомна трансформація

import { Transform } from 'class-transformer';
import { IsString } from 'class-validator';

export class CreateArticleDto {
  @IsString()
  title: string;

  // Автоматична генерація slug з title
  @Transform(({ obj }) => {
    if (!obj.slug && obj.title) {
      return obj.title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-|-$/g, '');
    }
    return obj.slug;
  })
  @IsString()
  slug?: string;
}

Тепер якщо клієнт надішле:

{
  "title": "My Great Article!"
}

Slug автоматично буде згенеровано як "my-great-article".

Обробка помилок валідації

Коли валідація не проходить, NestJS автоматично повертає статус 400 Bad Request з детальним описом помилок. Структуру відповіді можна кастомізувати.

Стандартна відповідь про помилку

{
  "statusCode": 400,
  "message": [
    "name must be at least 2 characters",
    "email must be an email",
    "password must be at least 8 characters"
  ],
  "error": "Bad Request"
}

Кастомізація повідомлень

Налаштування у main.ts:

// main.ts
import { ValidationPipe, BadRequestException } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
    exceptionFactory: (errors) => {
      // Кастомна структура помилок
      const formattedErrors = errors.map((error) => ({
        field: error.property,
        constraints: error.constraints,
        value: error.value,
      }));

      return new BadRequestException({
        statusCode: 400,
        message: 'Validation failed',
        errors: formattedErrors,
      });
    },
  }),
);

Тепер відповідь матиме структуру:

{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "field": "email",
      "constraints": {
        "isEmail": "email must be an email"
      },
      "value": "not-an-email"
    },
    {
      "field": "age",
      "constraints": {
        "min": "age must not be less than 13"
      },
      "value": 10
    }
  ]
}

Best Practices: рекомендації роботи з тілом запиту

1. Завжди використовуйте DTO з валідацією

@Post()
create(@Body() body: any) {
  // Відсутність валідації, немає автодоповнення
  return this.usersService.create(body);
}

2. Використовуйте mapped types для DRY

import { PartialType, OmitType, PickType, IntersectionType } from '@nestjs/mapped-types';

// Базовий DTO
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
  role: string;
}

// Часткове оновлення (всі поля опціональні)
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// Без пароля (виключити поле)
export class UserResponseDto extends OmitType(CreateUserDto, ['password']) {}

// Тільки name та email
export class BasicUserDto extends PickType(CreateUserDto, ['name', 'email']) {}

// Комбінація типів
export class ExtendedUserDto extends IntersectionType(
  CreateUserDto,
  class { @IsInt() age: number; }
) {}

3. Ніколи не зберігайте паролі у відкритому вигляді

async create(createUserDto: CreateUserDto): Promise<User> {
  // ❌ НІКОЛИ не робіть так:
  // await this.userRepository.save(createUserDto);

  // ✅ Завжди хешуйте паролі:
  const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
  const user = this.userRepository.create({
    ...createUserDto,
    password: hashedPassword,
  });
  return this.userRepository.save(user);
}

4. Видаляйте чутливі дані з відповідей

async findOne(id: number): Promise<User> {
  const user = await this.userRepository.findOne({ where: { id } });
  
  // ❌ Не повертайте пароль:
  // return user;

  // ✅ Видаліть чутливі поля:
  const { password, ...safeUser } = user;
  return safeUser as User;
}

// Або використовуйте class-transformer з @Exclude():
export class User {
  id: number;
  name: string;
  email: string;
  
  @Exclude() // Це поле не буде серіалізовано
  password: string;
}

5. Валідуйте бізнес-правила у сервісі

// ❌ Не всі перевірки можна виконати через декоратори
export class CreateUserDto {
  @IsEmail()
  email: string;
  // Як перевірити унікальність email через декоратор?
}

// ✅ Бізнес-валідація у сервісі
async create(createUserDto: CreateUserDto): Promise<User> {
  // Перевірка унікальності email
  const existingUser = await this.userRepository.findOne({
    where: { email: createUserDto.email },
  });

  if (existingUser) {
    throw new ConflictException('Email already exists');
  }

  // Створення користувача
  return this.userRepository.save(createUserDto);
}

6. Використовуйте whitelist та forbidNonWhitelisted

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,           // Видалити незадекларовані поля
    forbidNonWhitelisted: true, // Кинути помилку при невідомих полях
  }),
);

Це захищає від mass assignment attacks:

// Клієнт намагається встановити role через створення користувача
POST /users
{
  "name": "Hacker",
  "email": "hack@example.com",
  "password": "pass",
  "role": "admin"  // ⚠️ Потенційна атака
}

Якщо role не задекларовано у CreateUserDto, воно буде:

  • Видалено (при whitelist: true)
  • Кинуто помилку (при forbidNonWhitelisted: true)

7. Документуйте DTO через Swagger

import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEmail, MinLength } from 'class-validator';

export class CreateUserDto {
  @ApiProperty({
    description: 'Full name of the user',
    example: 'John Doe',
    minLength: 2,
    maxLength: 50,
  })
  @IsString()
  @MinLength(2)
  name: string;

  @ApiProperty({
    description: 'User email address',
    example: 'john@example.com',
  })
  @IsEmail()
  email: string;

  @ApiProperty({
    description: 'User password (min 8 characters)',
    example: 'SecurePass123',
    minLength: 8,
  })
  @IsString()
  @MinLength(8)
  password: string;
}

Це автоматично генерує Swagger-документацію для вашого API.

Комплексний приклад: CRUD операції з валідацією

Підсумуємо все, що вивчили, у повноцінному прикладі контролера з усіма операціями:

// dto/create-product.dto.ts
import { IsString, IsNumber, Min, IsOptional, IsArray, ArrayMinSize, IsIn } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateProductDto {
  @IsString()
  @MinLength(3)
  @MaxLength(100)
  name: string;

  @IsString()
  @MinLength(10)
  description: string;

  @Type(() => Number)
  @IsNumber()
  @Min(0)
  price: number;

  @Type(() => Number)
  @IsNumber()
  @Min(0)
  stock: number;

  @IsOptional()
  @IsIn(['electronics', 'clothing', 'books', 'food'])
  category?: string;

  @IsOptional()
  @IsArray()
  @ArrayMinSize(1)
  @IsString({ each: true })
  tags?: string[];
}
// dto/update-product.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateProductDto } from './create-product.dto';

export class UpdateProductDto extends PartialType(CreateProductDto) {}
// products.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Body,
  Param,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';

@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  // CREATE - POST /products
  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createProductDto: CreateProductDto) {
    const product = await this.productsService.create(createProductDto);
    return {
      message: 'Product created successfully',
      data: product,
    };
  }

  // READ ALL - GET /products
  @Get()
  async findAll() {
    return this.productsService.findAll();
  }

  // READ ONE - GET /products/:id
  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.productsService.findOneOrFail(id);
  }

  // UPDATE (FULL) - PUT /products/:id
  @Put(':id')
  async replace(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateProductDto: UpdateProductDto,
  ) {
    const product = await this.productsService.replace(id, updateProductDto);
    return {
      message: 'Product replaced successfully',
      data: product,
    };
  }

  // UPDATE (PARTIAL) - PATCH /products/:id
  @Patch(':id')
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateProductDto: UpdateProductDto,
  ) {
    const product = await this.productsService.update(id, updateProductDto);
    return {
      message: 'Product updated successfully',
      data: product,
    };
  }

  // DELETE - DELETE /products/:id
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
    await this.productsService.remove(id);
  }
}
// products.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './entities/product.entity';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  async create(createProductDto: CreateProductDto): Promise<Product> {
    // Перевірка унікальності назви
    const existingProduct = await this.productRepository.findOne({
      where: { name: createProductDto.name },
    });

    if (existingProduct) {
      throw new ConflictException('Product with this name already exists');
    }

    const product = this.productRepository.create(createProductDto);
    return this.productRepository.save(product);
  }

  async findAll(): Promise<Product[]> {
    return this.productRepository.find();
  }

  async findOneOrFail(id: number): Promise<Product> {
    const product = await this.productRepository.findOne({ where: { id } });

    if (!product) {
      throw new NotFoundException(`Product with ID ${id} not found`);
    }

    return product;
  }

  async replace(id: number, updateProductDto: UpdateProductDto): Promise<Product> {
    await this.findOneOrFail(id); // Перевірка існування

    // Повна заміна
    await this.productRepository.update(id, updateProductDto);
    return this.findOneOrFail(id);
  }

  async update(id: number, updateProductDto: UpdateProductDto): Promise<Product> {
    const product = await this.findOneOrFail(id);

    // Часткове оновлення
    Object.assign(product, updateProductDto);
    return this.productRepository.save(product);
  }

  async remove(id: number): Promise<void> {
    const result = await this.productRepository.delete(id);

    if (result.affected === 0) {
      throw new NotFoundException(`Product with ID ${id} not found`);
    }
  }
}

Підсумок: тіло запиту для створення та оновлення ресурсів

Тіло запиту є основним способом передачі структурованих даних у HTTP-запитах, що змінюють стан сервера. Воно дозволяє клієнтам надсилати складні об'єкти, вкладені структури та масиви, що неможливо через параметри URL.

Ключові принципи роботи з тілом запиту:

  • Декоратор @Body: Для витягування всього тіла або окремих полів
  • DTO: Data Transfer Objects для типізації, валідації та документування
  • class-validator: Автоматична валідація через декоратори (@IsString, @IsEmail, @Min тощо)
  • class-transformer: Трансформація та санітизація даних (@Type, @Transform)
  • POST: Створення нових ресурсів (статус 201 Created)
  • PUT: Повна заміна ресурсу (ідемпотентна операція)
  • PATCH: Часткове оновлення (гнучкіше та зручніше за PUT)
  • ValidationPipe: Глобальна валідація з опціями whitelist та forbidNonWhitelisted
  • Безпека: Хешування паролів, видалення чутливих даних, захист від mass assignment

Комбінування цих можливостей дозволяє створювати надійні API з автоматичною валідацією, чіткою типізацією та захистом від некоректних даних.

✅ Що ми опанували

  • Призначення тіла запиту та його відмінності від параметрів URL
  • Витягування даних з тіла через декоратор @Body
  • Створення DTO для структурованої типізації вхідних даних
  • Автоматичну валідацію через class-validator з десятками декораторів
  • Трансформацію та санітизацію даних через class-transformer
  • Створення ресурсів через POST з валідацією
  • Повну заміну (PUT) vs часткове оновлення (PATCH)
  • Валідацію вкладених об'єктів та масивів через @ValidateNested
  • Best practices для безпечної обробки вхідних даних
  • Захист від mass assignment attacks через whitelist

🎯 Наступні кроки

У лекції 11 ми вивчимо декоратори заголовків (@Headers, @Req, @Res), що дозволяють працювати з HTTP-заголовками та повним об'єктом запиту/відповіді:

  • Витягування заголовків через @Headers
  • Робота з об'єктом запиту через @Req
  • Управління відповіддю через @Res
  • Кастомні заголовки та CORS
  • Cookie та сесії
Copyright © 2026