Проблема возникает, когда сервис принимает хорошо сформированный JSON, но отклоняет операцию позже: лимит оказался недоступен, курс валюты устарел, а ресурс уже изменился. Цена смешения слоёв — неясная ошибка 400/409, повторные попытки клиента и спор о том, где именно нарушен контракт.
Причина обычно в широком слове «валидировать». Им называют проверку JSON-типа, обязательных полей, доступа пользователя и текущего состояния базы одновременно. Такой обработчик трудно тестировать: непонятно, какой вход должен быть отклонён схемой, а какой — доменной проверкой. Разделим эти решения и соберём минимальный фильтр, который можно запустить без сервера.
Три слоя, которые нельзя склеивать
Первый слой — структура: объект, строка, число, массив, обязательность, формат и перечисление. JSON Schema хорошо подходит для такого вопроса. Второй слой — локальный инвариант: например, minAmount <= maxAmount или допустимый размер страницы. Его можно проверять кодом после разбора JSON, если правило зависит от нескольких полей. Третий слой — состояние системы: существует ли пользователь, не занят ли ресурс, не истёк ли токен. Этот слой требует доступа к данным и обычно возвращает другой класс ошибки.
Если все три проверки спрятаны в одной схеме, описание начинает обещать больше, чем может проверить. Если всё оставить коду контроллера, клиенты теряют раннюю документацию и точное сообщение о форме. Рабочая граница проходит там, где появляется внешний контекст: схема описывает сам документ, доменная функция — связь полей, сервис — состояние и права.
| Слой | Пример | Результат ошибки | Подход |
|---|---|---|---|
| Тип и обязательность | limit — integer, required | 400: malformed document | JSON Schema или генератор клиента |
| Диапазон | 1 ≤ limit ≤ 100 | 400: invalid value | Schema minimum/maximum плюс тест |
| Связь полей | from <= to | 400: inconsistent filter | Чистая функция с двумя полями |
| Состояние | ресурс не изменён после чтения | 409: state conflict | Версия, условный запрос, транзакция |
| Право | роль может менять статус | 403: forbidden | Авторизация до изменения состояния |
Schema не делает неизвестное допустимым
У JSON Schema есть важное свойство: ограничения должны быть явными. Для API-фильтра можно разрешить limit, cursor и state, а остальные свойства закрыть через additionalProperties: false в нужном месте схемы. Но закрытость должна соответствовать расширяемости интерфейса. Если команда добавляет служебное поле без версионирования, строгая схема станет источником неожиданных отказов.
Есть и другая ловушка — использовать format как доказательство полной корректности. Формат даты или URI задаёт синтаксическую подсказку, но не подтверждает, что дата разрешена для операции или что URI принадлежит доверенному домену. Слово «valid» в отчёте должно иметь уточнение: valid по схеме, valid для инварианта или valid в текущем состоянии.
Runnable-пример: форма и инвариант по отдельности
В следующем фрагменте функция принимает фильтр поиска. Она проверяет форму и диапазон, добавляет безопасные значения по умолчанию и возвращает нормализованный объект. Это не библиотека JSON Schema, а маленький учебный аналог, на котором видно место бизнес-правила. Состояние базы и право доступа намеренно не притворяются частью результата.
import { validateFilterInput } from './upgrade-2027-09.mjs';
const good = validateFilterInput({ state: 'active', limit: 25 });
const bad = validateFilterInput({ state: 'active', limit: 250 });
const unknown = validateFilterInput({ state: 'active', region: 'eu' });
console.log(good.ok, good.value.limit, good.value.cursor);
console.log(bad.ok, bad.reason);
console.log(unknown.ok, unknown.value.state);
// true 25 null
// false limit-out-of-range
// true activeПорядок разложения проверки
- Опишите JSON-документ отдельно от команды, которая его использует. Назовите поля, типы, обязательность и допустимые значения.
- Выберите закрытую или расширяемую модель неизвестных полей. Решение должно быть одинаковым для сервера и клиентов, иначе один слой будет отвергать данные другого.
- Вынесите связи нескольких полей в чистые функции. Каждая функция должна иметь отрицательный пример и возвращать имя нарушенного правила.
- Присвойте класс ошибки: malformed input, invalid value, conflict или forbidden. Не превращайте конфликт состояния в повторную отправку 400.
- Проверьте, какие правила требуют чтения базы или другого сервиса. Для них зафиксируйте порядок проверки и условия гонки.
- Сверьте документацию и код на одном fixture-наборе. Расхождение между схемой и runtime-валидатором должно ломать сборку тестов.
Почему 409 важнее ещё одного boolean
Когда форма запроса корректна, но состояние изменилось, клиенту нужна возможность выбрать действие: перечитать ресурс, показать конфликт или прекратить операцию. Boolean вроде valid: false стирает причину. HTTP-семантика и локальный API-контракт должны различать ошибку документа и невозможность применить правильный документ к текущему состоянию.
Это различие помогает и с повторными попытками. Ошибка схемы не станет правильной от второго запроса, а конфликт иногда исчезает после нового чтения. Если оба случая имеют один статус, клиент либо повторяет бесполезную отправку, либо молча теряет возможность безопасного разрешения. Хорошая валидация уменьшает число retry-циклов именно тем, что сообщает границу отказа.
Ограничения и следующий шаг
Учебная функция не реализует полный draft 2020–12, не строит JSON Pointer к ошибке и не читает доменное состояние. Она показывает архитектурное разделение, а не заменяет валидатор библиотеки. В реальном API необходимо проверить выбранную библиотеку на oneOf, ссылки, форматы и поведение при неизвестных ключах.
Следующий шаг — взять один endpoint с конфликтом состояния и выписать три независимых теста: неправильная форма, нарушенный инвариант и устаревшая версия ресурса. После этого сравните их статусы и сообщения с документацией. Если один тест требует данных, которых нет в запросе, не расширяйте схему вслепую: это сигнал, что правило относится к сервисному слою.
Проверяемые источники
- OpenAPI Specification 3.1.1 — OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1. Применение: Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым. Граница: Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.
- JSON Schema Core 2020-12 — JSON Schema, draft 2020-12, спецификация Core. Применение: Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов. Граница: Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.
- RFC 9110 — HTTP Semantics — IETF, июнь 2022 года, RFC 9110, Standards Track. Применение: Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость. Граница: Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.