← назад к разделу

REST API · RESOURCES Фаза 15. REST API

URL и ресурсы в NestJS

TypeScript · NestJS · HTTP

Предсказуемые пути, версии, HTTP-методы и статусы.

1. Controller собирает путь ресурса

1 / 5

@Controller("orders") задаёт коллекцию, @Get(":id") адресует элемент. Глобальный prefix и URI versioning добавляют /api/v1 централизованно.

/api/v1@Controller orders@Get(':id') → /42

2. Путь называет ресурс

2 / 5

Используют lowercase kebab-case и существительные во множественном числе. HTTP-метод выражает действие, поэтому getAll и .json в пути не нужны.

Resource URL/order-items/orders/42/itemsnouns · kebab-caseAvoid/getAll/order_items/list.jsonverbs · extensions

3. Метод описывает операцию

3 / 5

GET читает, POST создаёт, PUT заменяет, PATCH меняет часть, DELETE удаляет. Вложенный путь показывает принадлежность, но не строит бесконечную иерархию.

GET · readPOST · createPATCH · updateDELETE · remove/orders/42/itemsitems belong to order 42

4. Статус подтверждает результат

4 / 5

Создание возвращает 201 и Location, чтение 200, удаление часто 204. @HttpCode меняет успешный статус там, где POST означает действие, а не создание.

200 OKrepresentation201 CreatedLocation header204 No Contentempty body

5. Служебные маршруты не версионируют

5 / 5

Health и readiness остаются стабильными для балансировщика. Global prefix исключает health, а VERSION_NEUTRAL убирает версию из его пути.

Business API/api/v1/orders/api/v2/ordersversioned contractOperational API/health · /readyVERSION_NEUTRALstable probe address

URL — это первое, что видит разработчик, который подключается к вашему API. Хорошо спроектированный путь читается как предложение: GET /orders/42/items — «дай мне позиции заказа №42». Плохо спроектированный заставляет открывать документацию на каждый шаг. В этой статье разберём, как NestJS устроен изнутри и какие правила помогут сделать API предсказуемым.

Как NestJS строит URL из кода

В NestJS каждый контроллер отвечает за свой префикс пути. Базовый адрес ресурса задаётся в @Controller, а конкретные эндпоинты — в декораторах методов.

@Controller('orders')           // /orders
export class OrdersController {

  @Get()                        // GET /orders
  findAll() {}

  @Get(':id')                   // GET /orders/:id
  findOne(@Param('id') id: string) {}
}

Чтобы не писать /api/v1 в каждом контроллере вручную, настройте это один раз при запуске приложения.

Глобальный префикс и версионирование

В main.ts два вызова дают единый формат для всех роутов:

import { VersioningType } from '@nestjs/common';

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

  app.setGlobalPrefix('api');
  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  await app.listen(3000);
}

После этого контроллер @Controller('orders') автоматически отвечает по адресу /api/v1/orders.

Служебные пути — health, ready — должны быть вне этой цепочки, иначе балансировщик придётся настраивать с учётом версии:

@Controller('health')
export class HealthController {
  @Get()
  check() { return { status: 'ok' }; }
}
// → GET /health  (без /api, без версии)

Чтобы контроллер не получил глобальный префикс, его исключают при настройке: app.setGlobalPrefix('api', { exclude: ['health'] }). А чтобы маршрут отвечал без версии в пути, на нём ставят @Version(VERSION_NEUTRAL) — тогда /health работает и без /v1.

Как называть пути

Строчные буквы и дефисы

Стандарт для URL — kebab-case: всё строчными через дефис. Никакого camelCase, никакого snake_case.

@Controller('order-items')   // правильно
@Controller('OrderItems')    // неправильно — заглавные
@Controller('order_items')   // неправильно — подчёркивание
@Controller('orderItems')    // неправильно — camelCase

Без глаголов и расширений

Путь называет существительное — ресурс, а не действие. Действие выражает HTTP-метод.

@Get(':id')         // правильно: /orders/:id
@Get('getAll')      // неправильно: глагол в пути
@Get('list.json')   // неправильно: расширение файла

Без финального слеша

Express под NestJS к слешу в конце нестрогий: /orders и /orders/ попадают в один маршрут, и объявленный как ':id/' путь примет оба варианта. Но в контракте и документации путь всегда пишут без слеша, и в декораторе его не объявляют — иначе спецификация OpenAPI и реальные адреса разойдутся.

@Get(':id')         // правильно: /orders/:id
@Get(':id/')        // неправильно: trailing slash

Коллекции и одиночные ресурсы

Коллекция — множественное число, одиночный ресурс — единственное. Правило простое: если по одному пути вернётся список — используйте множественное число.

@Controller('orders')    // коллекция — /orders, /orders/:id
@Controller('products')  // коллекция — /products, /products/:id
@Controller('order')     // неправильно для коллекции

Если ресурс всегда один на пользователя — единственное число:

@Controller('profile')   // один профиль текущего пользователя
export class ProfileController {
  @Get()                 // GET /api/v1/profile
  get() {}
}

Имя ресурса берите из предметной области: orders, а не purchases; customers, а не users. Когда слово из бизнеса совпадает с именем в коде — API понятен без дополнительных объяснений.

HTTP-методы

Каждый метод имеет чёткое назначение. Выбор метода — это часть контракта, который видит клиент.

@Controller('orders')
export class OrdersController {

  @Get()                       // GET /api/v1/orders → 200
  findAll() {}

  @Get(':id')                  // GET /api/v1/orders/:id → 200
  findOne(@Param('id') id: string) {}

  @Post()                      // POST /api/v1/orders → 201
  create(@Body() dto: CreateOrderDto) {}

  @Put(':id')                  // PUT /api/v1/orders/:id → 200
  replace(@Param('id') id: string, @Body() dto: ReplaceOrderDto) {}

  @Patch(':id')                // PATCH /api/v1/orders/:id → 200
  update(@Param('id') id: string, @Body() dto: UpdateOrderDto) {}

  @Delete(':id')
  @HttpCode(204)               // DELETE → 204 No Content
  remove(@Param('id') id: string) {}
}

На что обратить внимание:

  • POST при создании возвращает 201 Created — NestJS делает это автоматически.
  • DELETE по умолчанию в NestJS возвращает 200, но правильный ответ — 204 No Content. Нужно явно добавить @HttpCode(204).
  • GET не должен менять данные. Если нужна доменная команда (например, «отменить заказ»), используйте POST с именем действия в пути:
@Get(':id/cancel')   // неправильно: GET меняет состояние
cancel() {}

@Post(':id/cancel')  // правильно: команда через POST
@HttpCode(200)
cancel() {}

Вложенность ресурсов

Когда один ресурс логически принадлежит другому, это можно выразить в пути:

@Controller('orders')
export class OrdersController {

  @Get(':orderId/items')                // GET /api/v1/orders/:orderId/items
  getItems(@Param('orderId') orderId: string) {}

  @Get(':orderId/items/:itemId')        // GET /api/v1/orders/:orderId/items/:itemId
  getItem(
    @Param('orderId') orderId: string,
    @Param('itemId') itemId: string,
  ) {}
}

Параметры именуются уникально: orderId и itemId, а не оба id — это требование инструментов документации вроде Swagger.

Максимальная глубина — два уровня вложенности. Три уровня и глубже делают URL нечитаемым и сложным для кэширования. Вместо этого используйте плоский ресурс с фильтрацией:

// неправильно: три уровня
@Get(':userId/orders/:orderId/items/:itemId')

// правильно: плоский ресурс с параметром запроса
@Get()                                    // GET /api/v1/items?orderId=...
findAll(@Query('orderId') orderId: string) {}

Идентификатор ресурса передаётся в пути, не в теле запроса:

@Put(':id')                              // правильно
replace(@Param('id') id: string) {}

@Put()                                   // неправильно: id в body
replace(@Body() dto: { id: string }) {}

Коротко

  • Пути — kebab-case, строчные, без глаголов, без слеша в конце.
  • setGlobalPrefix('api') + VersioningType.URI дают /api/v1/... для всех контроллеров автоматически.
  • Служебные пути (/health, /ready) — вне глобального префикса.
  • Коллекции — множественное число (/orders), одиночный ресурс — единственное (/profile).
  • Имя ресурса — из предметной области, а не технические синонимы.
  • GET не меняет данные; доменные команды — через POST.
  • DELETE возвращает 204 No Content — нужен @HttpCode(204).
  • Максимум два уровня вложенности; глубже — плоский ресурс с фильтрацией.
  • Параметры в пути именуются уникально (orderId, itemId).

Что почитать дальше