Files

8 lines
24 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &amp;&amp; date.getUTCMonth() + 1 === Number(match[2])\n &amp;&amp; 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 &gt; 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) =&gt; {\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 &lt;&gt; '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 &gt; 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>"
}