{ "index": 11, "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "JSON Schema проверяет форму документа, но не знает права пользователя и текущее состояние ресурса. Разбираем границу между схемой, доменным правилом и безопасной записью.", "contentHtml": "

Клиент отправляет команду на изменение заказа. Обязательные поля на месте, типы верны, JSON Schema пропускает документ. Через несколько миллисекунд сервис отвечает отказом: заказ уже оплачен, ревизия устарела или роль пользователя не даёт права на изменение. Если все ветки записать как validation failed, клиент может повторить необратимое действие, оператор увидит лишнюю операцию, а разработчик начнёт искать причину то в схеме, то в базе. Цена ошибки — потерянное время и риск изменить ресурс второй раз.

\n

Здесь слово «valid» отвечает только на вопрос о документе. Для операции нужны ещё три ответа: согласованы ли поля между собой, имеет ли актор право на действие и можно ли применить его к текущему состоянию. Практическое правило — назначать проверку по источнику контекста. Вход достаточно проверить схемой, связь полей — чистой функцией, права — политикой авторизации, а ревизию и переход состояния — внутри защищённой операции.

\n

Четыре вопроса вместо одного «valid»

\n

Форма. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из объявленных значений. Такая проверка читает только вход. Для одинакового документа она должна давать одинаковый результат и не обращаться к базе, часам или сети.

\n

Инвариант. Отдельные поля могут быть корректны, но противоречить друг другу. Диапазон требует, чтобы from не был позже to; команда отмены не должна одновременно содержать действие «оплатить». Если правило зависит только от входного объекта, его удобно выразить чистой функцией и покрыть положительным и отрицательным тестом.

\n

Право. Схема не знает, кто отправил запрос, к какому ресурсу относится роль и не отозвано ли разрешение. Проверка canEditOrder(actor, orderId) использует контекст субъекта и политики. Её результат нельзя получить из добавленного в JSON поля role: такое поле не доказывает подлинность полномочий.

\n

Состояние. Документ может быть безупречным, но заказ уже перешёл в paid, склад изменился или ревизия стала другой. Сервис должен проверить это рядом с записью — в транзакции или условном обновлении. Предварительное чтение без защиты от гонки оставляет окно, в котором другой запрос успеет изменить ресурс.

\n
Слой проверки и его граница
СлойНужный контекстПример отказаОтвет сервиса
ФормаТолько документlimit — строка400, исправить вход
ИнвариантНесколько полей документаfrom позже to400 или 422 по контракту
ПравоАктор и политика ресурсаРоль не может отменить заказ403, не повторять без изменения полномочий
СостояниеАктуальный ресурс и ревизияЗаказ уже оплачен409 или 412, перечитать либо разрешить конфликт
\n

Коды в последнем столбце — часть конкретного API, а не автоматический вывод схемы. Важно, чтобы клиент различал ошибку входа, запрет и конфликт состояния: у них разные исправления и разные правила повторной попытки.

\n

Где заканчивается JSON Schema

\n

JSON Schema описывает экземпляр документа: типы, обязательность, границы чисел, перечисления, структуру объектов и политику дополнительных ключей. Ключевой нюанс — наличие имени в properties не делает поле обязательным; для этого нужен required. Аналогично, additionalProperties: false ловит опечатки, но превращает добавление нового поля в изменение контракта. Решение о закрытой или расширяемой форме нужно принять для конкретного endpoint и закрепить тестом.

\n

У ключевого слова format есть отдельная граница. В Draft 2020-12 оно относится к vocabulary форматов-аннотаций; assertion-проверка формата включается отдельным vocabulary и поддержкой валидатора. Поэтому запись format: 'date' сама по себе не даёт права утверждать, что любой runtime отвергнёт несуществующую календарную дату. Если отказ обязателен, настройте валидатор явно или добавьте проверку, которую можно вызвать и протестировать.

\n

OpenAPI 3.1 использует Schema Object как расширение JSON Schema и помогает описать входы и выходы HTTP-интерфейса. Это документация контракта, а не доказательство того, что handler действительно проверяет те же поля. Схема, сгенерированные типы и runtime-валидатор должны быть связаны проверкой сборки или интеграционным тестом; один из них не заменяет остальные.

\n
\"Матрица
У одного запроса четыре независимые границы. Рисунок показывает источник контекста и пример класса ответа; точные коды закрепляются контрактом endpoint.
\n

Учебный пример: схема, чистое правило и условная запись

\n

Ниже — учебный фрагмент без HTTP-сервера и базы. В нём отдельно видны форма и межполевая проверка. Поле format оставлено намеренно: рядом есть явная проверка календарной даты, чтобы пример не зависел от скрытой настройки конкретной библиотеки.

\n
const filterSchema = {\n  $schema: 'https://json-schema.org/draft/2020-12/schema',\n  type: 'object',\n  required: ['limit', 'from', 'to'],\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\nfunction isCalendarDate(value) {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n  if (!match) return false;\n\n  const date = new Date(`${value}T00:00:00Z`);\n  return date.getUTCFullYear() === Number(match[1])\n    && date.getUTCMonth() + 1 === Number(match[2])\n    && date.getUTCDate() === Number(match[3]);\n}\n\nfunction validateRange(input) {\n  if (!isCalendarDate(input.from) || !isCalendarDate(input.to)) {\n    return { ok: false, reason: 'invalid-date' };\n  }\n  if (input.from > input.to) {\n    return { ok: false, reason: 'from-after-to' };\n  }\n  return { ok: true };\n}\n\nconst good = { limit: 25, from: '2027-09-10', to: '2027-09-12' };\nconst badRange = { ...good, from: '2027-09-13' };\nconsole.log(validateRange(badRange));\n// { ok: false, reason: 'from-after-to' }
\n

Самостоятельно запустить можно последнюю функцию в Node.js или перенести её в тест выбранного языка. Вызов валидатора схемы здесь не привязан к конкретной библиотеке: её API различается. Задача примера — определить форму документа, затем проверить связь дат. Для настоящего endpoint положительный документ и случаи с неправильным типом, лишним ключом, несуществующей датой и обратным диапазоном должны попасть в один набор тестов.

\n

Для операции добавляется контекст актора и ресурса. Пример использует getForUpdate как условное имя блокирующего чтения. В конкретной базе нужно доказать, что блокировка, уровень изоляции и обработка ошибок действительно защищают запись.

\n
async function updateOrder(command, actor) {\n  const shape = validateCommandShape(command);\n  if (!shape.ok) return httpError(400, 'invalid-shape', shape.errors);\n\n  const invariant = validateOrderCommand(command);\n  if (!invariant.ok) return httpError(422, 'invalid-command', invariant.reason);\n\n  if (!canEditOrder(actor, command.orderId)) {\n    return httpError(403, 'forbidden');\n  }\n\n  return db.transaction(async (tx) => {\n    const order = await tx.orders.getForUpdate(command.orderId);\n    if (!order) return httpError(404, 'not-found');\n\n    if (order.state === 'paid' || order.revision !== command.expectedRevision) {\n      return httpError(409, 'state-conflict');\n    }\n\n    return tx.orders.update(command.orderId, {\n      ...command.patch,\n      revision: order.revision + 1\n    });\n  });\n}
\n

Здесь 400, 422, 403, 404 и 409 — выбранные значения учебного API. RFC 9110 описывает 409 Conflict как конфликт с текущим состоянием ресурса, а 422 Unprocessable Content — как синтаксически корректное содержимое, инструкции которого сервер не может обработать. Если ресурс отдаёт ETag, HTTP предлагает условие If-Match для защиты от потерянного обновления; провал этого precondition обычно выражают 412. Не смешивайте этот стандартный механизм с собственным полем revision без явного контракта.

\n

Код не является готовым ORM-рецептом. При отсутствии блокирующего чтения можно использовать атомарное обновление с условием WHERE id = ? AND revision = ? AND state <> 'paid' и проверить число изменённых строк. Ошибка сети после записи тоже требует решения: без idempotency key или операции, которую можно безопасно найти повторно, клиент не знает, была ли команда применена.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
limit строкой проходит в handlerСхема не подключена к runtime или проверяется не тот объектВызвать реальный handler с неправильным типомОстановить запрос до доменного кода
Схема пропускает обратный диапазонСвязь полей не закреплена в assertion-правилеЗапустить чистый тест from > toВернуть 400/422 с кодом причины
Правильная команда получает отказ без записиАктор не проходит политику ресурсаПроверить actor, ресурс и решение authorizationВернуть 403, не повторять тот же запрос
Повторная команда иногда меняет уже изменённый заказПроверка ревизии была до транзакции или нет идемпотентностиПовторить команду при конкурентном обновлении и потере ответаСделать условную запись и определить безопасный retry
Клиент бесконечно повторяет конфликт409/412 смешан с временным сетевым отказомСверить статус, reason code и факт изменения ресурсаПеречитать состояние или остановить retry
\n

Порядок внедрения

\n
  1. Опишите команду. Зафиксируйте метод, endpoint, media type, обязательные поля и форму успешного ответа.
  2. Закройте вопрос формы. Выберите dialect, required, типы, границы, enum, политику дополнительных ключей и поведение format. Положительный и отрицательный примеры храните рядом.
  3. Вынесите инварианты. Для каждого правила укажите входы и результат. Если функция не читает внешний контекст, протестируйте её отдельно.
  4. Проверьте полномочия. Перед изменением назовите актора, ресурс и policy decision. Поле role из тела запроса не заменяет доверенный контекст.
  5. Защитите состояние. Сверяйте ревизию и допустимый state transition в транзакции или условном update. Проверьте отсутствие записи и конкурентное изменение.
  6. Разделите ответы. Для формы, инварианта, запрета и конфликта задайте reason code, HTTP status и следующий шаг клиента. Не отправляйте все ошибки в один validation failed.
  7. Определите повтор. Для безопасных операций допустим retry, для конфликта нужен новый read, а для команды с побочным эффектом — idempotency key или lookup результата.
  8. Проверьте реальную границу. Запустите handler с теми же данными, которыми тестировали схему. Убедитесь, что при каждом отрицательном сценарии запись не изменилась.
\n

Ограничения механизма

\n

JSON Schema не превращается в движок доменных правил от добавления новых keywords. Пользовательские vocabulary и расширения могут быть полезны, но их должны одинаково понимать все участники контракта. Иначе документ будет выглядеть строгим в одном валидаторе и почти свободным в другом.

\n

Блокировка строки и условное обновление — разные реализации одной цели. Их поведение зависит от базы, драйвера, уровня изоляции, таймаутов и обработки deadlock. Учебный вызов getForUpdate не доказывает отсутствие гонки; это нужно подтвердить тестом на конкурентные операции и проверкой числа записанных строк.

\n

HTTP-статус не сообщает всей причины. 409 может требовать перечитать ресурс, 412 — обновить условие, 403 — прекратить попытку, а 422 — исправить смысл команды. В теле ответа нужны стабильный код причины и данные, которые клиенту разрешено показать. Политику retry нельзя выводить только из класса 4xx или 5xx.

\n

Наконец, успешная проверка документа не доказывает, что операция безопасна во всех сценариях. Надёжный вывод скромнее: неправильная форма остановлена на границе, локальное правило имеет отрицательный тест, полномочия проверены по доверенному контексту, а конкурентная запись не проходит без определённого конфликта.

\n

Критерий готовности

\n

Endpoint готов к проверке, когда команда может показать положительный сценарий и отрицательные ветки. Правильная форма проходит. Неправильный тип или лишний ключ останавливаются схемой. Обратный диапазон останавливается инвариантом. Запрещённый актор получает 403. Устаревшая ревизия или оплаченный заказ не меняются и возвращают конфликт. Потеря ответа не приводит к слепому повторному побочному эффекту. Для каждой ветки указаны источник контекста, reason code, статус и следующий шаг клиента.

\n

Если тест схемы проходит, а handler принимает другой документ, контракт не подключён. Если два параллельных запроса оба записывают одну ревизию, граница состояния стоит слишком рано. Если клиент повторяет 409/412 до бесконечности, API не различает конфликт и временный сбой. Эти наблюдаемые проверки возвращают статью к исходному вопросу: валидный JSON — необходимое условие, но не разрешение на операцию.

\n

Проверяемые источники

" }