Express Router и границы адресов
В server.js уже находятся общие middleware и адреса заявок. По мере добавления чтения одной записи, изменения состояния и вложений точка входа станет перегруженной. Express Router позволит собрать связанные адреса в отдельном модуле. Приложение назначит общий префикс /api/tickets, а router будет описывать только оставшуюся часть пути.
Исходное состояние — stages/07. В stages/08 появляются ticket-router.js и фабрика createTicketRouter. Передадим ей массив начальных заявок. Так router не будет самостоятельно читать файл при импорте: владелец приложения задаёт источник данных и время подготовки. После перехода к PostgreSQL сохранятся адреса и направление зависимостей.
Префикс и локальный путь
Полный router поддерживает список и чтение по идентификатору. Создание пока демонстрирует приём тела, а не сохраняет строку. Числовой параметр проверяется до поиска, чтобы неверное написание не стало случайным обращением к другой заявке.
import { Router } from 'express';
export function createTicketRouter(tickets) {
const router = Router();
router.get('/', (req, res) => res.json(tickets));
router.get('/:id', (req, res) => {
if (!/^[1-9][0-9]*$/.test(req.params.id)) {
return res.status(400).json({ error: 'invalid_id' });
}
const id = Number(req.params.id);
const ticket = tickets.find((item) => item.id === id);
if (!ticket) return res.status(404).json({ error: 'ticket_not_found' });
res.json(ticket);
});
router.post('/', (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) return res.status(400).json({ error: 'invalid_title' });
res.json({ accepted: title });
});
return router;
}
В server.js импортируем фабрику и подключаем результат после общих middleware: app.use('/api/tickets', createTicketRouter(tickets)). Поэтому локальный /:id обслуживает полный адрес /api/tickets/1. Не повторяйте префикс внутри router, иначе получите неожиданное вложение /api/tickets/api/tickets/1. Внутренняя группа адресов должна знать только свою часть пути.
Фабрика возвращает новый объект router с замкнутой ссылкой на переданный массив. Это ещё не внедрение сложного контейнера зависимостей; обычного параметра достаточно, чтобы сделать владение данными заметным. Принцип пригодится при выделении хранилища, когда вместо массива передадим функции доступа. Назначение Router описано в API Express.
Три источника входных значений
У запроса есть параметры пути, строка запроса и тело. req.params.id относится к /:id, req.query.status мог бы относиться к ?status=open, а req.body.title приходит из JSON. Не следует выбирать значение из любого места «по удобству». Один явный договор уменьшает неоднозначность и позволяет клиенту понимать, что он изменяет.
Например, GET /api/tickets/1?id=2 в нашем router читает заявку с номером один. Параметр строки запроса не подменяет сегмент пути. Если обработчик в разных местах берёт то params, то query, клиент может отправить противоречивые значения, а журнал станет трудно интерпретировать. Выберите один источник каждой части операции и проверяйте его явно.
req.params.id является строкой. Проверка написания нужна до преобразования, иначе parseInt('1abc') даст единицу и спрячет ошибку. В следующих главах общая функция ограничит диапазон integer. Сейчас ключи всего два, но уже видно различие неверного параметра и корректного номера отсутствующей записи. Первое получает 400, второе — 404.
Порядок внутри группы
Если позже появится адрес /summary, его следует регистрировать перед общим /:id либо использовать отдельную структуру маршрутов. Иначе слово summary может сначала попасть в проверку числового идентификатора. Это не повод добавлять большое количество исключений в валидатор: структура адресов сама должна выражать различие коллекции, её служебного действия и отдельного ресурса.
Общий обработчик неизвестного адреса остаётся в приложении после router. Например, /api/unknown не соответствует группе, а /api/tickets/1/history пока не имеет локального маршрута. В обоих случаях цепочка доходит до общей ошибки. При необходимости router может иметь собственный ответ для неизвестной операции, но в нашем небольшом API единый слой удобнее и обеспечивает одинаковый формат.
Middleware можно подключить ко всей группе. Когда появится проверка пользователя, её расположение перед createTicketRouter защитит все адреса заявок. Дополнительная проверка роли сотрудника будет стоять перед изменением состояния. Такая композиция понятнее, чем повторение одинакового кода в каждом методе, но требует внимательно проверять, что новый маршрут действительно находится за нужной границей.
Что router не должен делать
Разделение файлов не исправляет смешение обязанностей само по себе. Если router одновременно читает JSON, вычисляет новые номера, записывает файлы и отправляет ответы, сложность только переместилась. Сейчас он получает подготовленный массив. В следующем уроке операции с массивом уйдут в хранилище, а контроллер будет связывать их с HTTP.
Также импорт модуля не должен незаметно открывать второй сетевой сервер. Единственная точка входа вызывает listen. Router является частью приложения, а не самостоятельным сервисом с собственным портом. Это позволяет в будущем создать приложение отдельно от запуска и ясно назначить владельца пула и других ресурсов.
Наблюдаемый результат этой ступени — прежний список и чтение /api/tickets/1 через модульную группу. Для номера три ожидается 404, для 1abc — 400. Префикс задан один раз, общие middleware продолжают назначать контекст. Теперь можно выделить контроллер и хранилище, чтобы постоянная база изменила только слой данных.
Составной адрес ресурса полезно читать слева направо. /api обозначает договор приложения, /tickets выбирает область, следующий сегмент идентифицирует запись. Позднее /attachments выберет связанные вложения этой заявки. Такой путь задаёт ожидание связи: обработчик обязан проверять, что вложение принадлежит именно указанной записи. Само красивое написание URL этой проверки не выполняет.
Router допускает повторное использование с другим префиксом, но это не означает, что одна и та же группа автоматически подходит любому сервису. Контроллеры зависят от предметного договора заявок. Перенос к пользователям потребует других полей и правил. В нашей серии повторное использование касается способа композиции, а не обещания универсального CRUD для всех таблиц.
Список отличается от одиночной записи формой ответа. При чтении коллекции отсутствие подходящих заявок даёт пустой массив, а при чтении конкретного идентификатора отсутствие даёт 404. Не возвращайте пустой объект для обоих случаев ради одинаковой формы: клиент потеряет смысл операции. После появления пагинации коллекция получит объект с items и next, и это изменение будет описано отдельно. Для сегодняшней ступени договор массива остаётся неизменным.
Сегменты пути автоматически поступают в обработчик после работы маршрутизатора. Если имя параметра изменится с :id на :ticketId, обращение req.params.id перестанет получать значение. Переименование должно затронуть и договор обработчика. Для серии сохраняем короткое id во всех адресах заявки, а позже используем отдельное attachmentId для вложения. Так два идентификатора невозможно случайно спутать только по одинаковому имени.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.