Пошук уроків, статей та іншого контенту
Додасте query-параметри для фільтрації, сортування й безпечного формування запитів до даних.
API часто повертає не всі записи одразу, а лише потрібну підмножину:
фільтрація залишає записи, які відповідають умовам;
сортування визначає порядок записів;
пагінація обмежує кількість записів у відповіді.
Наприклад, клієнт може надіслати запит:
GET /products?category=books&minPrice=100&maxPrice=500&sortBy=price&order=ASC&page=1&limit=20Такий запит означає:
категорія товару — books;
ціна — від
500сортування за ціною за зростанням;
перша сторінка;
не більше 20 записів.
Query-параметри надходять у контролер як рядки. Тому їх потрібно:
перевірити;
перетворити на потрібні типи;
обмежити допустимі значення;
безпечно передати в запит до бази даних.
Створимо DTO для параметрів списку товарів.
// src/products/dto/product-query.dto.ts
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum ProductSortField {
NAME = 'name',
PRICE = 'price',
CREATED_AT = 'createdAt',
}
export enum SortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export class ProductQueryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
category?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minPrice?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
maxPrice?: number;
@IsOptional()
@IsEnum(ProductSortField)
sortBy: ProductSortField = ProductSortField.CREATED_AT;
@IsOptional()
@IsEnum(SortOrder)
order: SortOrder = SortOrder.DESC;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}Декоратор @Type(() => Number) перетворює значення з URL на число:
?minPrice=100перетворюється на:
minPrice: 100Без такого перетворення значення залишалося б рядком "100".
@Max(100) обмежує максимальний розмір сторінки. Це захищає API від запитів на кшталт limit=1000000, які можуть створити зайве навантаження на базу даних.
Щоб DTO перевірявся автоматично, налаштуйте ValidationPipe у main.ts.
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(3000);
}
bootstrap();Основні опції:
transform: true вмикає перетворення типів;
whitelist: true прибирає властивості, яких немає в DTO;
forbidNonWhitelisted: true повертає помилку замість мовчазного ігнорування невідомих параметрів.
Наприклад, параметр unknown=value не пройде валідацію, якщо його немає в ProductQueryDto.
Нехай у базі даних є сутність товару.
// src/products/product.entity.ts
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('products')
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
category: string;
@Column({ type: 'int' })
price: number;
@CreateDateColumn()
createdAt: Date;
}Контролер приймає DTO через @Query() і передає його сервісу.
// src/products/products.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ProductQueryDto } from './dto/product-query.dto';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll(@Query() query: ProductQueryDto) {
return this.productsService.findAll(query);
}
}Контролер не повинен сам будувати SQL-запит. Його завдання — отримати параметри та передати їх сервісу.
Для складених умов зручно використовувати QueryBuilder TypeORM.
// src/products/products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import {
ProductQueryDto,
ProductSortField,
} from './dto/product-query.dto';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productsRepository: Repository<Product>,
) {}
async findAll(query: ProductQueryDto) {
const {
category,
minPrice,
maxPrice,
sortBy,
order,
page,
limit,
} = query;
const queryBuilder = this.productsRepository
.createQueryBuilder('product')
.where('1 = 1');
if (category !== undefined) {
queryBuilder.andWhere('product.category = :category', {
category,
});
}
if (minPrice !== undefined) {
queryBuilder.andWhere('product.price >= :minPrice', {
minPrice,
});
}
if (maxPrice !== undefined) {
queryBuilder.andWhere('product.price <= :maxPrice', {
maxPrice,
});
}
// Клієнт може вибрати лише колонки з цього списку.
const sortColumns: Record<ProductSortField, string> = {
[ProductSortField.NAME]: 'product.name',
[ProductSortField.PRICE]: 'product.price',
[ProductSortField.CREATED_AT]: 'product.createdAt',
};
const sortColumn = sortColumns[sortBy];
queryBuilder
.orderBy(sortColumn, order)
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await queryBuilder.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}Для цього сервісу потрібні стандартні TypeORM-налаштування та реєстрація сутності в модулі:
// src/products/products.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './product.entity';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
imports: [TypeOrmModule.forFeature([Product])],
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}Умови фільтрації формуються так:
queryBuilder.andWhere('product.price >= :minPrice', {
minPrice,
});Значення minPrice передається окремо від тексту SQL. TypeORM сам підставляє його як параметр запиту.
Не слід вставляти значення безпосередньо в SQL-рядок:
// Небезпечний підхід
queryBuilder.andWhere(`product.category = '${category}'`);Такий підхід може створити SQL injection, якщо значення надходить від користувача.
Безпечний варіант:
// Безпечний підхід
queryBuilder.andWhere('product.category = :category', {
category,
});Параметризовані значення потрібно використовувати для всіх даних, які надходять із запиту:
категорій;
цін;
дат;
пошукових рядків;
ідентифікаторів;
інших значень фільтрів.
Значення імені колонки не можна передати до SQL як звичайний параметр:
// Так робити не можна
queryBuilder.orderBy('product.:sortBy', order);Параметри SQL призначені для значень, але не для назв таблиць або колонок. Тому назву колонки потрібно вибирати з наперед визначеного списку.
У DTO допустимі лише значення enum:
export enum ProductSortField {
NAME = 'name',
PRICE = 'price',
CREATED_AT = 'createdAt',
}У сервісі вони перетворюються на конкретні колонки:
const sortColumns: Record<ProductSortField, string> = {
[ProductSortField.NAME]: 'product.name',
[ProductSortField.PRICE]: 'product.price',
[ProductSortField.CREATED_AT]: 'product.createdAt',
};
const sortColumn = sortColumns[sortBy];
queryBuilder.orderBy(sortColumn, order);Клієнт може передати:
?sortBy=priceале не зможе вибрати довільний фрагмент SQL:
?sortBy=price%20DESC%2C%20idТакий підхід називається allowlist — список дозволених значень.
Так само напрямок сортування обмежений enum:
export enum SortOrder {
ASC = 'ASC',
DESC = 'DESC',
}У результаті дозволені лише ASC і DESC.
Фільтри minPrice і maxPrice застосовуються незалежно один від одного:
лише minPrice — ціна не нижча за вказану;
лише maxPrice — ціна не вища за вказану;
обидва параметри — ціна в заданому діапазоні.
Приклад:
GET /products?minPrice=100&maxPrice=500Відповідний запит міститиме дві умови:
product.price >= 100
AND product.price <= 500Важливо перевіряти значення через !== undefined, а не через просту перевірку на істинність:
if (minPrice !== undefined) {
// Працює також для minPrice = 0
}Перевірка такого виду може бути помилковою:
if (minPrice) {
// Значення 0 не потрапить усередину умови
}Нуль є допустимим числовим значенням, але в JavaScript він є false у булевому контексті.
Для сторінки використовується формула:
skip = (page - 1) * limit;При page=1 і limit=20:
skip = (1 - 1) * 20; // 0При page=3 і limit=20:
skip = (3 - 1) * 20; // 40Отже, третя сторінка починається з 41-го запису.
Метод getManyAndCount() повертає:
масив записів поточної сторінки;
загальну кількість записів, які відповідають фільтрам.
Це дозволяє клієнту визначити кількість сторінок:
totalPages: Math.ceil(total / limit)Наприклад:
{
"items": [],
"total": 47,
"page": 2,
"limit": 20,
"totalPages": 3
}Поле total має містити кількість усіх відповідних записів, а не лише кількість елементів на поточній сторінці.
Отримати товари за категорією:
GET /products?category=booksОтримати товари з мінімальною ціною:
GET /products?minPrice=100Отримати товари в діапазоні цін:
GET /products?minPrice=100&maxPrice=500Відсортувати за назвою:
GET /products?sortBy=name&order=ASCОтримати другу сторінку з 10 товарів, відсортованих за ціною:
GET /products?page=2&limit=10&sortBy=price&order=DESCПоєднати декілька фільтрів:
GET /products?category=books&minPrice=100&maxPrice=500&sortBy=price&order=ASC&page=1&limit=20Усі фільтри в прикладі поєднуються оператором AND. Товар повинен відповідати кожній переданій умові.
Якщо клієнт передасть:
GET /products?limit=1000DTO не пройде валідацію через @Max(100).
Якщо клієнт передасть:
GET /products?order=RANDOMбуде помилка через @IsEnum(SortOrder).
Якщо клієнт передасть:
GET /products?minPrice=abcзначення не пройде перевірки @IsInt().
Якщо клієнт передасть:
GET /products?sortBy=descriptionзначення не пройде перевірки @IsEnum(ProductSortField), оскільки description не входить до дозволеного списку.
Небезпечно:
queryBuilder.andWhere(`product.category = '${category}'`);Безпечно:
queryBuilder.andWhere('product.category = :category', {
category,
});Небезпечно:
queryBuilder.orderBy(`product.${sortBy}`, order);Користувач може передати несподіване значення, яке змінить SQL-запит.
Безпечно використовувати enum і мапу дозволених колонок:
const sortColumns = {
name: 'product.name',
price: 'product.price',
createdAt: 'product.createdAt',
};limitЗапит без максимальної кількості записів може повернути надто великий результат і створити навантаження на базу даних.
Встановлюйте:
значення за замовчуванням;
мінімальне значення;
максимальне значення.
Query-параметри надходять як рядки. Без transform: true значення page і limit можуть залишитися рядками, що призведе до неправильних обчислень або помилок валідації.
Неправильно:
if (minPrice) {
// Значення 0 буде пропущено
}Правильно:
if (minPrice !== undefined) {
// Значення 0 обробляється коректно
}Якщо DTO дозволяє createdAt, а мапа очікує created_at, значення не буде знайдено. Назви дозволених значень і ключі мапи повинні збігатися.
Query-параметри описуються окремим DTO.
ValidationPipe перевіряє параметри та перетворює їхні типи.
Фільтри потрібно додавати умовно, лише якщо параметр передано.
Значення фільтрів передаються до QueryBuilder через параметри :name.
Назви колонок для сортування потрібно обмежувати allowlist.
Напрямок сортування варто перевіряти через enum.
Пагінація повинна мати значення за замовчуванням і максимальний limit.
Не можна вставляти дані користувача безпосередньо в SQL-рядки.