Контроллер заявок и HTTP-маршруты
Теперь связуем предметные методы с HTTP. TicketsController объявляет три маршрута: список, отдельная карточка и изменение. Он получает query, id, body и actor из контекста запроса, а затем вызывает service. Результат главы — тонкая транспортная граница с явным различием между адресом объекта, данными редактирования и удостоверенной стороной. SQL здесь не появляется, поэтому смысл маршрута можно прочитать без изучения устройства pool.
Controller('tickets') задаёт общий участок пути. Main добавляет глобальный prefix api, поэтому конечные адреса начинаются /api/tickets. Get без аргумента соответствует списку, Get(':id') — карточке, Patch(':id') — записи. Не включайте api ещё раз в Controller: получится другой адрес, который Angular не ожидает. Общий префикс выбран на уровне bootstrap, чтобы все предметные контроллеры будущего кабинета использовали одну серверную конфигурацию.
Входные параметры
Ниже полный controller конечной версии. Swagger decorators будут подробно разобраны позднее; пока они описывают формы успеха и отказа, не меняя предметную запись. ListTicketsDto и PatchTicketDto импортируются как runtime-классы, поскольку ValidationPipe использует их metadata. ActorRequest импортируется только как TypeScript-тип: request-object уже создаётся HTTP-платформой и guard. Различие runtime import и import type особенно полезно в ESM-профиле с decorators.
import { Controller, Get, Patch, Param, Query, Body, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { ApiBearerAuth, ApiBadRequestResponse, ApiForbiddenResponse, ApiNotFoundResponse, ApiUnauthorizedResponse, ApiConflictResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { TicketsService } from './tickets.service.js';
import { ListTicketsDto, PatchTicketDto, TicketResponse, TicketListResponse, ErrorResponse } from './dto.js';
import { DemoAuthGuard, DispatcherGuard } from './auth.js';
import type { ActorRequest } from './auth.js';
const validId = (id: string) => { if (!/^rq-[0-9]+$/.test(id)) throw new BadRequestException('Некорректный id заявки'); return id; };
@ApiTags('tickets') @ApiBearerAuth() @ApiUnauthorizedResponse({type:ErrorResponse}) @ApiBadRequestResponse({type:ErrorResponse})
@UseGuards(DemoAuthGuard)
@Controller('tickets')
export class TicketsController {
constructor(private readonly service: TicketsService) {}
@Get() @ApiOkResponse({type:TicketListResponse})
list(@Query() query: ListTicketsDto) { return this.service.list(query); }
@Get(':id') @ApiOkResponse({type:TicketResponse}) @ApiNotFoundResponse({type:ErrorResponse})
get(@Param('id') id: string) { return this.service.get(validId(id)); }
@Patch(':id') @UseGuards(DispatcherGuard) @ApiOkResponse({type:TicketResponse})
@ApiForbiddenResponse({type:ErrorResponse}) @ApiConflictResponse({type:ErrorResponse}) @ApiNotFoundResponse({type:ErrorResponse})
patch(@Param('id') id: string, @Body() value: PatchTicketDto, @Req() req: ActorRequest) {
return this.service.patch(validId(id),value,req.actor!);
}
}
Query привязывает query string к DTO списка. Param извлекает id из пути, Body — тело PATCH, Req предоставляет расширенный запрос с actor. Эти источники не взаимозаменяемы. Version идёт в JSON body изменения, а не в URL id. Actor не приходит как обычное body-поле. Title не определяет, какую строку менять: идентичность задаётся path id. Такой договор позволяет независимо менять содержание и проверять доступ к определённой предметной записи.
Функция validId требует rq- и цифры. Неверная форма, например abc, даёт 400: запрос адресует объект не по принятому соглашению. Правильный rq-999 доходит до repository и там ожидаемо даёт 404, поскольку записи нет. Разница помогает диагностике. Мы не пытаемся автоматически исправлять id или выбирать первую существующую заявку. Повтор GET с другим id является другой операцией, а не способом скрыть ошибочный адрес пользователя.
Возвращаемое значение
Методы возвращают Promise от service, и Nest формирует JSON-ответ стандартным способом. Controller не вызывает res.send и не смешивает framework-managed ответ с ручным ответом Express. Это оставляет status, фильтры и обработку исключений в общей цепочке. Для PATCH успешный статус здесь 200, соответствующий обновлённому Ticket. Не ожидайте 201 только потому, что внутри транзакции добавилась строка журнала: внешний ресурс не создаётся, операция изменяет существующую заявку.
Get списка отдаёт envelope {items,total}, а get одной заявки — сам Ticket. Клиент знает эти формы и проверяет unknown соответствующей функцией. Универсальная обёртка, внезапно добавленная interceptor к каждому ответу, нарушила бы контракт. Поэтому RequestTrace логирует контекст, но не оборачивает результаты в data. Это пример того, как transport-слой должен уважать договор: «удобный» общий ответ нельзя вводить без согласования с источниками и клиентом.
Class-level UseGuards(DemoAuthGuard) распространяется на все методы чтения и записи. Method-level DispatcherGuard добавляет ограничение для PATCH. ActorRequest содержит необязательный actor с точки зрения TypeScript, потому что сам тип запроса ещё не гарантирует прохождение guard. В patch используется req.actor после guards; assertion соответствует принятому graph. Если перенести метод в другой незакрытый controller, это предположение придётся пересмотреть, а не копировать non-null assertion по привычке.
Ошибки и работа без клиента
API можно изучать обычными HTTP-запросами из README: наличие Angular не является условием controller. Viewer GET ожидаемо возвращает данные, viewer PATCH — 403, неизвестный token — 401. Неверный title даёт 400 после проверки DTO, а устаревшая version — 409 после предметного чтения. Такой набор раскрывает несколько последовательных границ. Успех одной не доказывает успех следующих: корректный адрес и JSON ещё не гарантируют существование записи и актуальность основы.
Content-Type application/json необходим для обычного JSON-тела PATCH. Если внешний клиент отправит строку в другом формате, DTO не должен магически угадать нужную заявку. Валидация работает с тем объектом, который построила HTTP-платформа. Архив не добавляет multipart, upload или arbitrary content parser. Простой набор форматов помогает сосредоточиться на DTO и конкуренции version без отдельной задачи передачи файлов, лимитов размера и хранения вложений.
Controller не делает отдельный предварительный get перед patch. Такую проверку легко написать ради красивого 404, но состояние может измениться между двумя запросами к базе. Repository выполняет чтение текущей строки и обновление внутри одного transaction-client. HTTP-слою достаточно передать id и validated intention. Позднее транзакционная глава объяснит, почему именно владелец SQL решает порядок чтения, проверки версии и записи журнала.
При самостоятельном исследовании сравните body поля title со path id. Измените title rq-101 — ожидается та же запись с новой version. Измените path на rq-102 — ожидается другая предметная операция с её собственной основой. Попытка передать id в body будет отклонена как неизвестное поле после строгой ValidationPipe. Такой отказ не является излишней придиркой: он удерживает один однозначный источник адреса изменяемого объекта.
Далее введём decorators DTO и правила ValidationPipe. Controller уже показывает, где эти классы используются и почему они должны существовать во время выполнения. Полный архив содержит совместный набор исходников, но приложение не запускалось, поэтому ожидаемые status и JSON не представлены как полученная HTTP-трасса. Основной учебный результат этой главы — правильное направление данных и ясная транспортная поверхность, которую можно проверить независимо от клиентской формы.
Статус примера — not_run. Зависимости, приложение, SQL, HTTP и тесты не запускались. Описанные ответы и изменения интерфейса ожидаемые; чтение документации не является проверкой выполнением. Полная конечная версия находится в архиве исходников, а её ограничения и действия читателя — в README и CONTRACT.md.
Перейдите к оглавлению серии или общему маршруту интерактивной веб-разработки. Механизм этой главы сверяется с официальным первичным источником; конкретные версии и непроверенные части перечислены в договоре курса.