{ "index": 11, "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", "contentHtml": "
Проблема начинается с запроса на изменение заказа. JSON проходит проверку: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.
Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, соответствует ли экземпляр описанной форме. Доменная проверка отвечает, согласованы ли поля между собой. Сервис проверяет текущий ресурс, полномочия и возможность применить команду. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке нужен только сам документ, её можно выполнить на входе. Как только нужны база, часы, права или другой запрос, это уже правило операции с отдельным результатом и отдельным тестом.
\nНачните с вопроса о форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа и одинакового выбранного диалекта она должна давать одинаковый результат.
Затем проверьте связь полей. Диапазон дат требует, чтобы from не был позже to; сумма платежа должна соответствовать разрешённому типу операции; поле currency должно быть совместимо с суммой. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Часть таких условий выражается в JSON Schema через композицию и условные конструкции, если их поддерживает выбранный диалект и валидатор. Чистая функция часто читается лучше. Место проверки вторично по сравнению с тем, что правило названо и тестируется отдельно.
Наконец, проверьте применимость к состоянию. Документ может быть безупречным, но ресурс уже изменился: revision: 4 устарела, купон исчерпан или заказ перешёл в необратимый статус. Это не ошибка типа JSON. Клиенту нужно перечитать ресурс, показать конфликт или завершить сценарий без повторной отправки.
| Слой | Что проверяем | Пример отказа | Где выполнять |
|---|---|---|---|
| Форма | Тип, обязательность, диапазон, enum | limit не integer | JSON Schema или валидатор на входе |
| Инвариант | Связь нескольких полей | from позже to | Чистая доменная функция |
| Состояние | Актуальность и доступность ресурса | Ревизия уже изменилась | Репозиторий внутри согласованной операции |
| Право | Разрешение на команду | Роль не может отменить заказ | Политика авторизации до изменения состояния |
Схема фиксирует структуру экземпляра: типы, обязательные ключи, диапазоны, шаблоны, перечисления и вложенные объекты. Она полезна как исполняемый контракт для одинаковой проверки разных потребителей и как документация, которую могут использовать инструменты. Но результат означает только соответствие экземпляра описанным ограничениям. Из него не следует, что заказ существует сейчас, пользователь имеет доступ или внешняя система согласилась на операцию.
\nСлово «формат» требует осторожности. В JSON Schema Draft 2020-12 vocabulary format разделён на аннотацию и assertion: реализация может собирать информацию о формате, но не обязана отклонять экземпляр только из-за неё, если не включён режим проверки форматов. Поэтому для дат, URI и email нужно зафиксировать диалект, библиотеку и настройки. Если дата важна для домена, дополните схему явным правилом и тестом, а не рассчитывайте на одинаковое поведение всех валидаторов.
Неизвестные поля требуют отдельного решения. Закрытый объект с additionalProperties: false ловит опечатку, но усложняет расширение контракта. Открытый объект легче развивать, но ошибочный ключ может быть молча проигнорирован потребителем. Выберите политику для конкретного endpoint, внесите её в схему и проверьте отрицательным примером. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12, но является его надмножеством с собственным диалектом; не переносите предположения о поведении одной реализации в другую.
Ниже — запускаемый учебный фрагмент без HTTP-сервера, базы и внешних пакетов. Объект filterSchema показывает намерение контракта, а небольшая функция имитирует только нужный для примера набор проверок. Это не реализация JSON Schema и не замена библиотеке: цель фрагмента — сделать видимым порядок «форма → инвариант» и дать отрицательные случаи, которые можно воспроизвести командой node validation-example.mjs.
import assert from 'node:assert/strict';\n\nconst filterSchema = {\n type: 'object',\n additionalProperties: false,\n properties: {\n limit: { type: 'integer', minimum: 1, maximum: 100 },\n from: { type: 'string', format: 'date' },\n to: { type: 'string', format: 'date' }\n }\n};\n\nconst isIsoDate = (value) => {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const parsed = new Date(`${value}T00:00:00Z`);\n return parsed.toISOString().slice(0, 10) === value;\n};\n\nfunction validateShape(input) {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, reason: 'object-required' };\n }\n const allowed = new Set(Object.keys(filterSchema.properties));\n if (Object.keys(input).some((key) => !allowed.has(key))) {\n return { ok: false, reason: 'unknown-property' };\n }\n if ('limit' in input && (!Number.isInteger(input.limit)\n || input.limit < 1 || input.limit > 100)) {\n return { ok: false, reason: 'limit-out-of-range' };\n }\n for (const key of ['from', 'to']) {\n if (key in input && !isIsoDate(input[key])) {\n return { ok: false, reason: `${key}-must-be-date` };\n }\n }\n return { ok: true };\n}\n\nfunction validateRange(input) {\n if (input.from && input.to && input.from > input.to) {\n return { ok: false, reason: 'from-after-to' };\n }\n return { ok: true };\n}\n\nassert.deepEqual(validateShape({\n limit: 25, from: '2027-09-10', to: '2027-09-12'\n}), { ok: true });\nassert.equal(validateShape({ limit: 0 }).ok, false);\nassert.equal(validateShape({ typo: 25 }).reason, 'unknown-property');\nassert.equal(validateRange({\n from: '2027-09-12', to: '2027-09-10'\n}).reason, 'from-after-to');\nconsole.log('shape and invariant checks passed');\nЗдесь сравнение дат безопасно только потому, что isIsoDate сначала проверяет календарную дату и приводит её к единому формату. Для более сложных календарей, часовых поясов и локального времени правило нужно заменить доменной библиотекой и тестами. В настоящем сервисе структурную часть передайте выбранному JSON Schema-валидатору, явно включите нужную проверку format, а функцию диапазона оставьте чистой и вызывайте после успешной проверки формы.
Проверка ревизии должна быть частью операции изменения, а не отдельным предварительным запросом. Если сначала прочитать заказ, затем проверить revision и только потом записать данные, второй клиент успеет изменить заказ между этими действиями. Получится классическая гонка: оба запроса увидели старое состояние, хотя применить можно было только один.
Практический вариант — передавать ожидаемую ревизию и делать условное обновление внутри транзакции: обновить запись только при совпадении идентификатора и версии, увеличить версию атомарно, а отсутствие обновлённой строки превратить в конфликт. Точный SQL, блокировки и уровень изоляции зависят от СУБД. Поэтому пример с getForUpdate нельзя копировать без проверки драйвера, а тест должен запускать два конкурентных изменения, а не только вызывать функцию два раза подряд.
Право — ещё один отдельный источник контекста. Ответ 403 говорит, что сервер понял запрос, но не разрешает действие этому субъекту; он не исправляется изменением revision. Если политика зависит от владельца, организации или состояния заказа, проверяйте её на том же представлении данных, для которого принимается решение. Не полагайтесь на скрытие кнопки в интерфейсе: клиент не является границей доверия.
Для конфликта текущего ресурса RFC 9110 определяет 409 Conflict и ожидает от сервера достаточно сведений, чтобы распознать источник конфликта. Для HTTP-precondition, заданного заголовками вроде If-Match, применяется отдельная семантика 412 Precondition Failed. Выбранный статус должен соответствовать реальному контракту API, а тело — содержать стабильный код причины и безопасное действие: перечитать ресурс, объединить изменения или прекратить повтор.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Валидатор принимает ключ, а клиент его не использует | Схема открыта или контракт не обновили | Сверить политику unknown keys и чтение поля | Закрыть объект либо документировать расширение |
| Правильный JSON получает 400 | Ошибка состояния скрыта под ошибкой формы | Разделить логи shape, invariant и state | Вернуть отдельный класс ошибки и исправить retry |
| Повторный запрос меняет уже изменённый ресурс | Нет идемпотентности или проверки версии | Повторить команду при конкурентном обновлении | Добавить ключ идемпотентности или условие версии |
| Тест схемы проходит, endpoint падает | Схема не покрывает runtime-ветку | Вызвать реальный handler с теми же данными | Добавить интеграционный тест на границе |
| Клиент бесконечно повторяет запрос | Конфликт обозначен как временная ошибка | Проверить статус, код причины и retry policy | Различить повторяемый отказ и конфликт ресурса |
validate: сначала определите документ и команду.format в выбранном валидаторе. Один и тот же ключ может быть аннотацией в одной конфигурации и отклонением в другой.Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от нескольких систем и измениться между проверкой и записью. В таких случаях нужно описать границу согласованности и риск, а не расширять JSON Schema до роли универсального движка правил.
\nHTTP-статус тоже не заменяет доменный контракт. 409 подходит для конфликта с текущим состоянием ресурса, но не обязан быть единственным выбором для каждого бизнес-отказа. 422 уместен, когда сервер понимает тип содержимого и не может обработать содержащиеся инструкции; конкретное применение согласуйте в API. Важна стабильная связь «причина → статус → действие», а не магическая цифра.
Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, право проверяется сервером, а конкурентное изменение не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов, ошибок интеграции или неправильной бизнес-модели.
\nРешение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, политика авторизации отклоняет запрещённого субъекта, а атомарное изменение отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий проходит через тот же порядок. Если на любой вопрос команда отвечает только «валидатор всё проверит», граница ещё не проведена.
\nformat. Используйте её для формы JSON; состояние ресурса и права остаются за пределами схемы.409 Conflict, 412 Precondition Failed и 422 Unprocessable Content. Применяйте его как опору для статусов, а не как замену доменному контракту.