8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 11,
|
||
"slug": "editorial-2027-09-mechanism-mentor-series",
|
||
"title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние",
|
||
"excerpt": "JSON Schema проверяет форму документа, но не знает права пользователя и текущее состояние ресурса. Разбираем границу между схемой, доменным правилом и безопасной записью.",
|
||
"contentHtml": "<p>Клиент отправляет команду на изменение заказа. Обязательные поля на месте, типы верны, JSON Schema пропускает документ. Через несколько миллисекунд сервис отвечает отказом: заказ уже оплачен, ревизия устарела или роль пользователя не даёт права на изменение. Если все ветки записать как <code>validation failed</code>, клиент может повторить необратимое действие, оператор увидит лишнюю операцию, а разработчик начнёт искать причину то в схеме, то в базе. Цена ошибки — потерянное время и риск изменить ресурс второй раз.</p>\n<p>Здесь слово «valid» отвечает только на вопрос о документе. Для операции нужны ещё три ответа: согласованы ли поля между собой, имеет ли актор право на действие и можно ли применить его к текущему состоянию. Практическое правило — назначать проверку по источнику контекста. Вход достаточно проверить схемой, связь полей — чистой функцией, права — политикой авторизации, а ревизию и переход состояния — внутри защищённой операции.</p>\n<h2>Четыре вопроса вместо одного «valid»</h2>\n<p><strong>Форма.</strong> Запрос должен быть объектом, <code>limit</code> — целым числом от 1 до 100, а <code>state</code> — одним из объявленных значений. Такая проверка читает только вход. Для одинакового документа она должна давать одинаковый результат и не обращаться к базе, часам или сети.</p>\n<p><strong>Инвариант.</strong> Отдельные поля могут быть корректны, но противоречить друг другу. Диапазон требует, чтобы <code>from</code> не был позже <code>to</code>; команда отмены не должна одновременно содержать действие «оплатить». Если правило зависит только от входного объекта, его удобно выразить чистой функцией и покрыть положительным и отрицательным тестом.</p>\n<p><strong>Право.</strong> Схема не знает, кто отправил запрос, к какому ресурсу относится роль и не отозвано ли разрешение. Проверка <code>canEditOrder(actor, orderId)</code> использует контекст субъекта и политики. Её результат нельзя получить из добавленного в JSON поля <code>role</code>: такое поле не доказывает подлинность полномочий.</p>\n<p><strong>Состояние.</strong> Документ может быть безупречным, но заказ уже перешёл в <code>paid</code>, склад изменился или ревизия стала другой. Сервис должен проверить это рядом с записью — в транзакции или условном обновлении. Предварительное чтение без защиты от гонки оставляет окно, в котором другой запрос успеет изменить ресурс.</p>\n<div class=\"table-scroll\"><table><caption>Слой проверки и его граница</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Нужный контекст</th><th scope=\"col\">Пример отказа</th><th scope=\"col\">Ответ сервиса</th></tr></thead><tbody><tr><td>Форма</td><td>Только документ</td><td><code>limit</code> — строка</td><td>400, исправить вход</td></tr><tr><td>Инвариант</td><td>Несколько полей документа</td><td><code>from</code> позже <code>to</code></td><td>400 или 422 по контракту</td></tr><tr><td>Право</td><td>Актор и политика ресурса</td><td>Роль не может отменить заказ</td><td>403, не повторять без изменения полномочий</td></tr><tr><td>Состояние</td><td>Актуальный ресурс и ревизия</td><td>Заказ уже оплачен</td><td>409 или 412, перечитать либо разрешить конфликт</td></tr></tbody></table></div>\n<p>Коды в последнем столбце — часть конкретного API, а не автоматический вывод схемы. Важно, чтобы клиент различал ошибку входа, запрет и конфликт состояния: у них разные исправления и разные правила повторной попытки.</p>\n<h2>Где заканчивается JSON Schema</h2>\n<p>JSON Schema описывает экземпляр документа: типы, обязательность, границы чисел, перечисления, структуру объектов и политику дополнительных ключей. Ключевой нюанс — наличие имени в <code>properties</code> не делает поле обязательным; для этого нужен <code>required</code>. Аналогично, <code>additionalProperties: false</code> ловит опечатки, но превращает добавление нового поля в изменение контракта. Решение о закрытой или расширяемой форме нужно принять для конкретного endpoint и закрепить тестом.</p>\n<p>У ключевого слова <code>format</code> есть отдельная граница. В Draft 2020-12 оно относится к vocabulary форматов-аннотаций; assertion-проверка формата включается отдельным vocabulary и поддержкой валидатора. Поэтому запись <code>format: 'date'</code> сама по себе не даёт права утверждать, что любой runtime отвергнёт несуществующую календарную дату. Если отказ обязателен, настройте валидатор явно или добавьте проверку, которую можно вызвать и протестировать.</p>\n<p>OpenAPI 3.1 использует Schema Object как расширение JSON Schema и помогает описать входы и выходы HTTP-интерфейса. Это документация контракта, а не доказательство того, что handler действительно проверяет те же поля. Схема, сгенерированные типы и runtime-валидатор должны быть связаны проверкой сборки или интеграционным тестом; один из них не заменяет остальные.</p>\n<figure><img src=\"/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg\" alt=\"Матрица слоёв проверки: форма JSON, связь полей, состояние ресурса и право доступа; для каждого слоя показаны нужный вопрос и пример ответа\" loading=\"lazy\" /><figcaption>У одного запроса четыре независимые границы. Рисунок показывает источник контекста и пример класса ответа; точные коды закрепляются контрактом endpoint.</figcaption></figure>\n<h2>Учебный пример: схема, чистое правило и условная запись</h2>\n<p>Ниже — учебный фрагмент без HTTP-сервера и базы. В нём отдельно видны форма и межполевая проверка. Поле <code>format</code> оставлено намеренно: рядом есть явная проверка календарной даты, чтобы пример не зависел от скрытой настройки конкретной библиотеки.</p>\n<pre><code>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' }</code></pre>\n<p>Самостоятельно запустить можно последнюю функцию в Node.js или перенести её в тест выбранного языка. Вызов валидатора схемы здесь не привязан к конкретной библиотеке: её API различается. Задача примера — определить форму документа, затем проверить связь дат. Для настоящего endpoint положительный документ и случаи с неправильным типом, лишним ключом, несуществующей датой и обратным диапазоном должны попасть в один набор тестов.</p>\n<p>Для операции добавляется контекст актора и ресурса. Пример использует <code>getForUpdate</code> как условное имя блокирующего чтения. В конкретной базе нужно доказать, что блокировка, уровень изоляции и обработка ошибок действительно защищают запись.</p>\n<pre><code>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}</code></pre>\n<p>Здесь <code>400</code>, <code>422</code>, <code>403</code>, <code>404</code> и <code>409</code> — выбранные значения учебного API. RFC 9110 описывает <code>409 Conflict</code> как конфликт с текущим состоянием ресурса, а <code>422 Unprocessable Content</code> — как синтаксически корректное содержимое, инструкции которого сервер не может обработать. Если ресурс отдаёт <code>ETag</code>, HTTP предлагает условие <code>If-Match</code> для защиты от потерянного обновления; провал этого precondition обычно выражают <code>412</code>. Не смешивайте этот стандартный механизм с собственным полем <code>revision</code> без явного контракта.</p>\n<p>Код не является готовым ORM-рецептом. При отсутствии блокирующего чтения можно использовать атомарное обновление с условием <code>WHERE id = ? AND revision = ? AND state <> 'paid'</code> и проверить число изменённых строк. Ошибка сети после записи тоже требует решения: без idempotency key или операции, которую можно безопасно найти повторно, клиент не знает, была ли команда применена.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика смешанной валидации</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td><code>limit</code> строкой проходит в handler</td><td>Схема не подключена к runtime или проверяется не тот объект</td><td>Вызвать реальный handler с неправильным типом</td><td>Остановить запрос до доменного кода</td></tr><tr><td>Схема пропускает обратный диапазон</td><td>Связь полей не закреплена в assertion-правиле</td><td>Запустить чистый тест <code>from > to</code></td><td>Вернуть 400/422 с кодом причины</td></tr><tr><td>Правильная команда получает отказ без записи</td><td>Актор не проходит политику ресурса</td><td>Проверить actor, ресурс и решение authorization</td><td>Вернуть 403, не повторять тот же запрос</td></tr><tr><td>Повторная команда иногда меняет уже изменённый заказ</td><td>Проверка ревизии была до транзакции или нет идемпотентности</td><td>Повторить команду при конкурентном обновлении и потере ответа</td><td>Сделать условную запись и определить безопасный retry</td></tr><tr><td>Клиент бесконечно повторяет конфликт</td><td>409/412 смешан с временным сетевым отказом</td><td>Сверить статус, reason code и факт изменения ресурса</td><td>Перечитать состояние или остановить retry</td></tr></tbody></table></div>\n<h2>Порядок внедрения</h2>\n<ol><li><strong>Опишите команду.</strong> Зафиксируйте метод, endpoint, media type, обязательные поля и форму успешного ответа.</li><li><strong>Закройте вопрос формы.</strong> Выберите dialect, <code>required</code>, типы, границы, enum, политику дополнительных ключей и поведение <code>format</code>. Положительный и отрицательный примеры храните рядом.</li><li><strong>Вынесите инварианты.</strong> Для каждого правила укажите входы и результат. Если функция не читает внешний контекст, протестируйте её отдельно.</li><li><strong>Проверьте полномочия.</strong> Перед изменением назовите актора, ресурс и policy decision. Поле <code>role</code> из тела запроса не заменяет доверенный контекст.</li><li><strong>Защитите состояние.</strong> Сверяйте ревизию и допустимый state transition в транзакции или условном update. Проверьте отсутствие записи и конкурентное изменение.</li><li><strong>Разделите ответы.</strong> Для формы, инварианта, запрета и конфликта задайте reason code, HTTP status и следующий шаг клиента. Не отправляйте все ошибки в один <code>validation failed</code>.</li><li><strong>Определите повтор.</strong> Для безопасных операций допустим retry, для конфликта нужен новый read, а для команды с побочным эффектом — idempotency key или lookup результата.</li><li><strong>Проверьте реальную границу.</strong> Запустите handler с теми же данными, которыми тестировали схему. Убедитесь, что при каждом отрицательном сценарии запись не изменилась.</li></ol>\n<h2>Ограничения механизма</h2>\n<p>JSON Schema не превращается в движок доменных правил от добавления новых keywords. Пользовательские vocabulary и расширения могут быть полезны, но их должны одинаково понимать все участники контракта. Иначе документ будет выглядеть строгим в одном валидаторе и почти свободным в другом.</p>\n<p>Блокировка строки и условное обновление — разные реализации одной цели. Их поведение зависит от базы, драйвера, уровня изоляции, таймаутов и обработки deadlock. Учебный вызов <code>getForUpdate</code> не доказывает отсутствие гонки; это нужно подтвердить тестом на конкурентные операции и проверкой числа записанных строк.</p>\n<p>HTTP-статус не сообщает всей причины. <code>409</code> может требовать перечитать ресурс, <code>412</code> — обновить условие, <code>403</code> — прекратить попытку, а <code>422</code> — исправить смысл команды. В теле ответа нужны стабильный код причины и данные, которые клиенту разрешено показать. Политику retry нельзя выводить только из класса <code>4xx</code> или <code>5xx</code>.</p>\n<p>Наконец, успешная проверка документа не доказывает, что операция безопасна во всех сценариях. Надёжный вывод скромнее: неправильная форма остановлена на границе, локальное правило имеет отрицательный тест, полномочия проверены по доверенному контексту, а конкурентная запись не проходит без определённого конфликта.</p>\n<h2>Критерий готовности</h2>\n<p>Endpoint готов к проверке, когда команда может показать положительный сценарий и отрицательные ветки. Правильная форма проходит. Неправильный тип или лишний ключ останавливаются схемой. Обратный диапазон останавливается инвариантом. Запрещённый актор получает 403. Устаревшая ревизия или оплаченный заказ не меняются и возвращают конфликт. Потеря ответа не приводит к слепому повторному побочному эффекту. Для каждой ветки указаны источник контекста, reason code, статус и следующий шаг клиента.</p>\n<p>Если тест схемы проходит, а handler принимает другой документ, контракт не подключён. Если два параллельных запроса оба записывают одну ревизию, граница состояния стоит слишком рано. Если клиент повторяет 409/412 до бесконечности, API не различает конфликт и временный сбой. Эти наблюдаемые проверки возвращают статью к исходному вопросу: валидный JSON — необходимое условие, но не разрешение на операцию.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-core.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Core 2020-12</a> — описывает экземпляры, keywords, vocabularies и разделение assertion/annotation. Используйте его, чтобы не приписывать схеме знание базы, актора или внешнего состояния.</li><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Validation 2020-12</a> — задаёт структурные validation keywords и два режима vocabulary для <code>format</code>. Сверяйте по нему обязательность полей, дополнительные свойства и настройку format assertion.</li><li><a href=\"https://spec.openapis.org/oas/v3.1.1.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.1</a> — описывает Schema Object как расширение JSON Schema для входных и выходных типов HTTP API. Он не подтверждает поведение конкретного handler.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — фиксирует смысл 409, 412 и 422, а также условие <code>If-Match</code>. Используйте стандарт для HTTP-семантики, а reason code и retry policy определяйте в своём API.</li></ul>"
|
||
}
|