Проверка данных и единые ошибки API
Создание заявки уже сохраняет данные PostgreSQL, но разные неверные запросы пока получают разные, иногда случайные ответы. Крупный заголовок проверяется в контроллере, неизвестное состояние отвергает база, неправильный JSON обрабатывает Express. Для клиента это один API, поэтому ему нужен согласованный способ понимать ошибку. Добавим проверку данных на входе и единую форму ответа, сохранив ограничения схемы как последний рубеж.
Работаем со состоянием stages/13. Основная задача — отделить ожидаемое нарушение договора от непредвиденного сбоя. Превращать все исключения в 400 удобно только внешне: клиент начнёт исправлять запрос, когда на самом деле недоступна база. Публиковать необработанный стек ещё хуже: он раскрывает структуру приложения и не даёт устойчивого правила обработки.
Нормализация перед операцией
В нашем договоре заголовок — строка от одного до ста двадцати символов после удаления крайних пробелов. Состояние — одно из трёх разрешённых значений. Идентификатор — положительное целое число в диапазоне PostgreSQL integer. Полный файл validation.js содержит небольшие функции; они не знают, в каком маршруте будут вызваны.
import { ApiError } from './errors.js';
export function ticketId(value) {
if (!/^[1-9][0-9]*$/.test(String(value))) {
throw new ApiError(400, 'invalid_id');
}
const id = Number(value);
if (!Number.isSafeInteger(id) || id > 2147483647) {
throw new ApiError(400, 'invalid_id');
}
return id;
}
export function ticketTitle(value) {
if (typeof value !== 'string') throw new ApiError(400, 'invalid_title');
const title = value.trim();
if (Array.from(title).length < 1 || Array.from(title).length > 120) {
throw new ApiError(400, 'invalid_title');
}
return title;
}
export function ticketStatus(value) {
if (!['open', 'working', 'closed'].includes(value)) {
throw new ApiError(400, 'invalid_status');
}
return value;
}
Проверка идентификатора сначала рассматривает написание строки. Это исключает значения вроде 1e2, 1.0 и пробелов, которые Number мог бы преобразовать в допустимое число. Для URL выбираем один простой формат. Регулярное выражение не делает запрос SQL безопасным само по себе: после проверки значение всё равно передаётся через параметр $1, а не вставляется в текст команды.
Для длины заголовка используем Array.from, чтобы считать кодовые точки, а не кодовые единицы UTF-16. У символа за пределами базовой плоскости JavaScript length может показать два. PostgreSQL считает символы иначе, поэтому без выбранного правила границы API и базы могли бы расходиться. Даже кодовая точка не равна видимому знаку: комбинация буквы и диакритики может занимать несколько точек. Здесь фиксируем технический предел, а интерфейс при необходимости поясняет его пользователю.
Нормализация удаляет только крайние пробелы. Она не переписывает внутренние пробелы и не преобразует содержимое в HTML. Заголовок остаётся данными. Если интерфейс позднее покажет его через безопасную текстовую операцию, апострофы и угловые скобки должны отображаться как текст. Проверка на сервере не заменяет корректное отображение на клиенте.
Известная ошибка и внутреннее исключение
ApiError несёт статус и короткий машинный код. Он не должен содержать секреты, текст запроса к базе или свободное исключение сторонней библиотеки. Полный модуль errors.js определяет класс и middleware Express.
export class ApiError extends Error {
constructor(status, code) {
super(code);
this.status = status;
this.code = code;
}
}
export function errorHandler(error, req, res, next) {
if (res.headersSent) return next(error);
let status = 500;
let code = 'internal_error';
if (error instanceof ApiError) {
status = error.status;
code = error.code;
} else if (error.type === 'entity.parse.failed') {
status = 400;
code = 'invalid_json';
} else if (error.type === 'entity.too.large') {
status = 413;
code = 'body_too_large';
}
console.error(JSON.stringify({ event: 'request_error', requestId: req.id, status, code }));
res.status(status).json({ error: { code, requestId: req.id } });
}
Четыре аргумента отличают обработчик ошибок от обычного middleware. Он подключается после маршрутов. В Express 5 исключение внутри возвращённой асинхронной функции доходит до этого обработчика автоматически; для отдельного callback или таймера механизм требует явного сопровождения. Поведение рассмотрено в официальном руководстве Express.
Если заголовки уже отправлены, заменить ответ новым JSON нельзя. Передаём ошибку дальше через next(error). Это особенно заметно при потоковой передаче файла: первые байты могут уйти клиенту до позднего сбоя чтения. Наши обычные JSON-ответы формируются целиком после завершения операции, что делает границу проще, но не отменяет правила протокола.
Наблюдаемый договор
Для POST /api/tickets с пустым заголовком ожидается 400 и код invalid_title. Для синтаксически неверного JSON — 400 и invalid_json. Для отсутствующей заявки с корректным идентификатором — 404 и ticket_not_found. Последний случай принципиально отличается от invalid_id: строка 25 имеет правильный формат, просто соответствующей записи нет.
Тело каждой ошибки содержит requestId, который уже появился в middleware предыдущих глав. Клиент может сообщить этот номер сотруднику поддержки, а сотрудник сопоставит ответ с журналом. Идентификатор связывает наблюдения; он не удостоверяет пользователя и не даёт доступа к его данным. Не путайте диагностический контекст с авторизацией, которую добавим позднее.
База по-прежнему проверяет ограничения. Например, другая служебная программа может отправить неверное состояние напрямую. Однако мы не переводим любую ошибку CHECK в пользовательский код автоматически. Если проверенный контроллер всё-таки отправил неверное значение, это может быть дефектом приложения. Узкие известные ситуации, например конфликт уникального адреса при регистрации, будут сопоставляться отдельно там, где известен смысл операции.
Устойчивый формат упрощает и развитие клиента. Интерфейс смотрит на машинный код, а текст объяснения выбирает сам. Нельзя строить логику по точному тексту исключения PostgreSQL: он может меняться с локалью и версией сервера. Полный результат этого урока — одинаковая форма ожидаемых ошибок и закрытый внутренний сбой. Следующие операции фильтрации и изменения истории будут использовать тот же договор.
Проверка формы тела не должна зависеть от того, какое поле случайно встретилось первым. Для создания ожидаем объект с заголовком, а для изменения — объект с состоянием. Массив, null и строка являются другими формами JSON. Защитное обращение req.body?.title помогает не упасть на некоторых случаях, но явный предметный договор всё равно нужен. В полном примере принимаются только конкретные используемые поля, остальные не получают права изменять модель.
Ошибки не должны исчезать из наблюдений после назначения красивого ответа. Код internal_error полезен клиенту своей устойчивостью, однако сопровождающему требуется закрытая диагностика причины. Учебный журнал выбирает ограниченные поля; в реальном окружении дополнительно сохраняют безопасный контекст исключения согласно политике сервиса. Публичный ответ и внутренний журнал имеют разных читателей. Не расширяйте первый ради удобства второго.
Сравните неизвестный адрес и отсутствие заявки. Первый говорит, что API не определяет такой путь, второй — что определённая операция не нашла доступный ресурс. На клиенте оба могут выглядеть как отсутствие страницы, но сопровождение различает их по коду. Согласованная структура не означает одинаковый смысл всех ошибок. Именно короткие отдельные коды сохраняют эту разницу при переводе человеческих сообщений.
Размер тела проверяется раньше предметной операции, поэтому превышение лимита не должно создавать строку. Аналогично неверный заголовок не должен успеть получить номер и попасть в историю. Проверка выполняется до вызова хранилища, а успешный ответ после него. Если эти действия поменять местами ради удобного сообщения, приложение начнёт оставлять побочные результаты ошибочных запросов. При чтении контроллера прослеживайте именно эту последовательность, а не только наличие отдельной функции валидации.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.