{ "index": 11, "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "JSON Schema проверяет форму документа, но не знает права пользователя и текущее состояние ресурса. Разбираем границу между схемой, доменным правилом и безопасной записью.", "contentHtml": "
Клиент отправляет команду на изменение заказа. Обязательные поля на месте, типы верны, JSON Schema пропускает документ. Через несколько миллисекунд сервис отвечает отказом: заказ уже оплачен, ревизия устарела или роль пользователя не даёт права на изменение. Если все ветки записать как validation failed, клиент может повторить необратимое действие, оператор увидит лишнюю операцию, а разработчик начнёт искать причину то в схеме, то в базе. Цена ошибки — потерянное время и риск изменить ресурс второй раз.
Здесь слово «valid» отвечает только на вопрос о документе. Для операции нужны ещё три ответа: согласованы ли поля между собой, имеет ли актор право на действие и можно ли применить его к текущему состоянию. Практическое правило — назначать проверку по источнику контекста. Вход достаточно проверить схемой, связь полей — чистой функцией, права — политикой авторизации, а ревизию и переход состояния — внутри защищённой операции.
\nФорма. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из объявленных значений. Такая проверка читает только вход. Для одинакового документа она должна давать одинаковый результат и не обращаться к базе, часам или сети.
Инвариант. Отдельные поля могут быть корректны, но противоречить друг другу. Диапазон требует, чтобы from не был позже to; команда отмены не должна одновременно содержать действие «оплатить». Если правило зависит только от входного объекта, его удобно выразить чистой функцией и покрыть положительным и отрицательным тестом.
Право. Схема не знает, кто отправил запрос, к какому ресурсу относится роль и не отозвано ли разрешение. Проверка canEditOrder(actor, orderId) использует контекст субъекта и политики. Её результат нельзя получить из добавленного в JSON поля role: такое поле не доказывает подлинность полномочий.
Состояние. Документ может быть безупречным, но заказ уже перешёл в paid, склад изменился или ревизия стала другой. Сервис должен проверить это рядом с записью — в транзакции или условном обновлении. Предварительное чтение без защиты от гонки оставляет окно, в котором другой запрос успеет изменить ресурс.
| Слой | Нужный контекст | Пример отказа | Ответ сервиса |
|---|---|---|---|
| Форма | Только документ | limit — строка | 400, исправить вход |
| Инвариант | Несколько полей документа | from позже to | 400 или 422 по контракту |
| Право | Актор и политика ресурса | Роль не может отменить заказ | 403, не повторять без изменения полномочий |
| Состояние | Актуальный ресурс и ревизия | Заказ уже оплачен | 409 или 412, перечитать либо разрешить конфликт |
Коды в последнем столбце — часть конкретного API, а не автоматический вывод схемы. Важно, чтобы клиент различал ошибку входа, запрет и конфликт состояния: у них разные исправления и разные правила повторной попытки.
\nJSON Schema описывает экземпляр документа: типы, обязательность, границы чисел, перечисления, структуру объектов и политику дополнительных ключей. Ключевой нюанс — наличие имени в properties не делает поле обязательным; для этого нужен required. Аналогично, additionalProperties: false ловит опечатки, но превращает добавление нового поля в изменение контракта. Решение о закрытой или расширяемой форме нужно принять для конкретного endpoint и закрепить тестом.
У ключевого слова format есть отдельная граница. В Draft 2020-12 оно относится к vocabulary форматов-аннотаций; assertion-проверка формата включается отдельным vocabulary и поддержкой валидатора. Поэтому запись format: 'date' сама по себе не даёт права утверждать, что любой runtime отвергнёт несуществующую календарную дату. Если отказ обязателен, настройте валидатор явно или добавьте проверку, которую можно вызвать и протестировать.
OpenAPI 3.1 использует Schema Object как расширение JSON Schema и помогает описать входы и выходы HTTP-интерфейса. Это документация контракта, а не доказательство того, что handler действительно проверяет те же поля. Схема, сгенерированные типы и runtime-валидатор должны быть связаны проверкой сборки или интеграционным тестом; один из них не заменяет остальные.
\nНиже — учебный фрагмент без HTTP-сервера и базы. В нём отдельно видны форма и межполевая проверка. Поле format оставлено намеренно: рядом есть явная проверка календарной даты, чтобы пример не зависел от скрытой настройки конкретной библиотеки.
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 как условное имя блокирующего чтения. В конкретной базе нужно доказать, что блокировка, уровень изоляции и обработка ошибок действительно защищают запись.
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 без явного контракта.
Код не является готовым ORM-рецептом. При отсутствии блокирующего чтения можно использовать атомарное обновление с условием WHERE id = ? AND revision = ? AND state <> 'paid' и проверить число изменённых строк. Ошибка сети после записи тоже требует решения: без idempotency key или операции, которую можно безопасно найти повторно, клиент не знает, была ли команда применена.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
limit строкой проходит в handler | Схема не подключена к runtime или проверяется не тот объект | Вызвать реальный handler с неправильным типом | Остановить запрос до доменного кода |
| Схема пропускает обратный диапазон | Связь полей не закреплена в assertion-правиле | Запустить чистый тест from > to | Вернуть 400/422 с кодом причины |
| Правильная команда получает отказ без записи | Актор не проходит политику ресурса | Проверить actor, ресурс и решение authorization | Вернуть 403, не повторять тот же запрос |
| Повторная команда иногда меняет уже изменённый заказ | Проверка ревизии была до транзакции или нет идемпотентности | Повторить команду при конкурентном обновлении и потере ответа | Сделать условную запись и определить безопасный retry |
| Клиент бесконечно повторяет конфликт | 409/412 смешан с временным сетевым отказом | Сверить статус, reason code и факт изменения ресурса | Перечитать состояние или остановить retry |
required, типы, границы, enum, политику дополнительных ключей и поведение format. Положительный и отрицательный примеры храните рядом.role из тела запроса не заменяет доверенный контекст.validation failed.JSON Schema не превращается в движок доменных правил от добавления новых keywords. Пользовательские vocabulary и расширения могут быть полезны, но их должны одинаково понимать все участники контракта. Иначе документ будет выглядеть строгим в одном валидаторе и почти свободным в другом.
\nБлокировка строки и условное обновление — разные реализации одной цели. Их поведение зависит от базы, драйвера, уровня изоляции, таймаутов и обработки deadlock. Учебный вызов getForUpdate не доказывает отсутствие гонки; это нужно подтвердить тестом на конкурентные операции и проверкой числа записанных строк.
HTTP-статус не сообщает всей причины. 409 может требовать перечитать ресурс, 412 — обновить условие, 403 — прекратить попытку, а 422 — исправить смысл команды. В теле ответа нужны стабильный код причины и данные, которые клиенту разрешено показать. Политику retry нельзя выводить только из класса 4xx или 5xx.
Наконец, успешная проверка документа не доказывает, что операция безопасна во всех сценариях. Надёжный вывод скромнее: неправильная форма остановлена на границе, локальное правило имеет отрицательный тест, полномочия проверены по доверенному контексту, а конкурентная запись не проходит без определённого конфликта.
\nEndpoint готов к проверке, когда команда может показать положительный сценарий и отрицательные ветки. Правильная форма проходит. Неправильный тип или лишний ключ останавливаются схемой. Обратный диапазон останавливается инвариантом. Запрещённый актор получает 403. Устаревшая ревизия или оплаченный заказ не меняются и возвращают конфликт. Потеря ответа не приводит к слепому повторному побочному эффекту. Для каждой ветки указаны источник контекста, reason code, статус и следующий шаг клиента.
\nЕсли тест схемы проходит, а handler принимает другой документ, контракт не подключён. Если два параллельных запроса оба записывают одну ревизию, граница состояния стоит слишком рано. Если клиент повторяет 409/412 до бесконечности, API не различает конфликт и временный сбой. Эти наблюдаемые проверки возвращают статью к исходному вопросу: валидный JSON — необходимое условие, но не разрешение на операцию.
\nformat. Сверяйте по нему обязательность полей, дополнительные свойства и настройку format assertion.If-Match. Используйте стандарт для HTTP-семантики, а reason code и retry policy определяйте в своём API.