Опирается на правила: 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 в queryR-QRY-X1customerId camelCase
page=0 в публичном контрактеR-QRY-X2page=1 (1-based)
?status=A,B comma-separatedR-QRY-X3?status=A&status=B
Бизнес-логика в query-параметреR-QRY-X4action-эндпоинт
Клиент парсит cursorR-QRY-X5opaque — передавать как есть
Числовой page без @Type(() => Number)@Type(() => Number) обязателен
transform: false в ValidationPipetransform: true глобально

Куда дальше

  • JSON и формат ответов — структура content + пагинация.
  • URL и ресурсы — формат пути, @Controller.
  • OpenAPI и антипаттерны — @ApiQuery для документирования.
  • REST API Style Guide (нормативно) — формулировки.