Опирается на правила:
R-QRY-1..9и X-коды из REST API Style Guide → раздел Query-параметры.
Важно знать
- camelCase — нативен для TypeScript, алиасы не нужны.
ValidationPipe({ transform: true })глобально — автоматическое приведение типов.@Type(() => Number)для числовых параметров (page,size).page— 1-based в публичном контракте.page=0— запрещён.- Массивы — повтор параметра (
?status=A&status=B), не CSV (?status=A,B).- Cursor — непрозрачный токен; клиент не парсит, не конструирует.
- Сложный поиск —
POST /resources/searchс JSON body.
NestJS + class-validator + transform: true делает Query-DTO идиоматичным решением: camelCase, автоматические типы, валидация декларативно.
Глобальная настройка
// main.ts
app.useGlobalPipes(
new ValidationPipe({
transform: true, // string → number/boolean/enum автоматически
whitelist: true, // отбрасывает лишние параметры
}),
);
Именование параметров
R-QRY-1: camelCase.
export class OrdersQueryDto {
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus; // ?status=CONFIRMED
@IsOptional()
@IsString()
customerId?: string; // ?customerId=550e...
@IsOptional()
@IsDateString()
createdAtFrom?: string; // ?createdAtFrom=2026-01-01T00:00:00Z
@IsOptional()
@IsDateString()
createdAtTo?: string; // ?createdAtTo=2026-12-31T23:59:59Z
}
// ✗ snake_case — нарушение R-QRY-X1
customer_id?: string;
// ✗ PascalCase
CustomerId?: string;
// ✓
customerId?: string;
Фильтрация и диапазоны
R-QRY-2..3:
export class ProductsQueryDto {
@IsOptional()
@IsString()
categoryId?: string; // ?categoryId=abc
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceFrom?: number; // ?priceFrom=100
@IsOptional()
@Type(() => Number)
@IsNumber()
@Max(1000000)
priceTo?: number; // ?priceTo=5000
}
Offset-based пагинация
R-QRY-4:
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1) // page 1-based — page=0 запрещён (R-QRY-X2)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size: number = 20;
}
Контроллер:
@Controller('orders')
export class OrdersController {
@Get()
@ApiOperation({ operationId: 'listOrders', summary: 'List orders with pagination' })
async findAll(
@Query() query: OrdersQueryDto,
@Query() pagination: PaginationDto,
) {
return this.ordersService.findAll(query, pagination);
}
}
Формат ответа — content + метаданные (см. JSON и формат ответов):
{
"content": [...],
"page": 1,
"size": 20,
"totalElements": 243,
"totalPages": 13
}
Cursor-based пагинация
R-QRY-5:
export class CursorPaginationDto {
@IsOptional()
@IsString()
cursor?: string; // opaque — клиент не парсит, не конструирует
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size: number = 20;
}
Ответ:
{
"content": [...],
"nextCursor": "eyJpZCI6IjU1MG...",
"hasMore": true
}
R-QRY-X5: cursor — непрозрачная строка (base64 от внутреннего состояния). Клиент передаёт её как есть.
Сортировка
R-QRY-6:
export class SortQueryDto {
@IsOptional()
@IsString()
@Matches(/^[a-zA-Z]+,(asc|desc)$/)
sort?: string; // ?sort=createdAt,desc
}
Парсинг на стороне сервиса:
function parseSort(sort?: string): { field: string; direction: 'asc' | 'desc' } | undefined {
if (!sort) return undefined;
const [field, direction] = sort.split(',');
return { field, direction: direction as 'asc' | 'desc' };
}
Полнотекстовый поиск
R-QRY-7:
export class SearchQueryDto {
@IsOptional()
@IsString()
@MinLength(2)
q?: string; // ?q=ноутбук
}
@Get()
async findAll(@Query() query: SearchQueryDto) {
if (query.q) return this.productsService.search(query.q);
return this.productsService.findAll();
}
Множественные значения
R-QRY-8 — повтор параметра, не CSV:
export class StatusFilterDto {
@IsOptional()
@IsArray()
@IsEnum(OrderStatus, { each: true })
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
status?: OrderStatus[]; // ?status=CONFIRMED&status=SHIPPED
}
Express разбирает ?status=A&status=B в массив ['A', 'B']. @Transform обеспечивает правильный тип при одном значении.
// ✗ comma-separated — R-QRY-X3
// ?status=CONFIRMED,SHIPPED
// ✓ повтор
// ?status=CONFIRMED&status=SHIPPED
Сложный поиск
R-QRY-9 — сложные запросы через POST /search:
export class OrderSearchDto {
@IsOptional()
@IsString()
customerId?: string;
@IsOptional()
@IsArray()
@IsEnum(OrderStatus, { each: true })
statuses?: OrderStatus[];
@IsOptional()
@IsDateString()
createdAtFrom?: string;
@IsOptional()
@Type(() => Number)
@Min(0)
totalAmountMin?: number;
}
@Controller('orders')
export class OrdersController {
@Post('search')
@HttpCode(200)
@ApiOperation({ operationId: 'searchOrders', summary: 'Search orders with complex criteria' })
async search(@Body() dto: OrderSearchDto) {
return this.ordersService.search(dto);
}
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
customer_id / CustomerID в query | R-QRY-X1 | customerId camelCase |
page=0 в публичном контракте | R-QRY-X2 | page=1 (1-based) |
?status=A,B comma-separated | R-QRY-X3 | ?status=A&status=B |
| Бизнес-логика в query-параметре | R-QRY-X4 | action-эндпоинт |
Клиент парсит cursor | R-QRY-X5 | opaque — передавать как есть |
Числовой page без @Type(() => Number) | — | @Type(() => Number) обязателен |
transform: false в ValidationPipe | — | transform: true глобально |
Куда дальше
- JSON и формат ответов — структура
content+ пагинация. - URL и ресурсы — формат пути,
@Controller. - OpenAPI и антипаттерны —
@ApiQueryдля документирования. - REST API Style Guide (нормативно) — формулировки.