8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 11,
|
||
"slug": "editorial-2027-09-mechanism-mentor-series",
|
||
"title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние",
|
||
"excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.",
|
||
"contentHtml": "<p>Проблема начинается с запроса на изменение заказа. JSON проходит проверку: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: <code>validation failed</code>. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.</p>\n<p>Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, соответствует ли экземпляр описанной форме. Доменная проверка отвечает, согласованы ли поля между собой. Сервис проверяет текущий ресурс, полномочия и возможность применить команду. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке нужен только сам документ, её можно выполнить на входе. Как только нужны база, часы, права или другой запрос, это уже правило операции с отдельным результатом и отдельным тестом.</p>\n<h2>Три вопроса вместо одного valid</h2>\n<p>Начните с вопроса о форме. Запрос должен быть объектом, <code>limit</code> — целым числом от 1 до 100, а <code>state</code> — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа и одинакового выбранного диалекта она должна давать одинаковый результат.</p>\n<p>Затем проверьте связь полей. Диапазон дат требует, чтобы <code>from</code> не был позже <code>to</code>; сумма платежа должна соответствовать разрешённому типу операции; поле <code>currency</code> должно быть совместимо с суммой. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Часть таких условий выражается в JSON Schema через композицию и условные конструкции, если их поддерживает выбранный диалект и валидатор. Чистая функция часто читается лучше. Место проверки вторично по сравнению с тем, что правило названо и тестируется отдельно.</p>\n<p>Наконец, проверьте применимость к состоянию. Документ может быть безупречным, но ресурс уже изменился: <code>revision: 4</code> устарела, купон исчерпан или заказ перешёл в необратимый статус. Это не ошибка типа JSON. Клиенту нужно перечитать ресурс, показать конфликт или завершить сценарий без повторной отправки.</p>\n<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><th scope=\"row\">Форма</th><td>Тип, обязательность, диапазон, enum</td><td><code>limit</code> не integer</td><td>JSON Schema или валидатор на входе</td></tr><tr><th scope=\"row\">Инвариант</th><td>Связь нескольких полей</td><td><code>from</code> позже <code>to</code></td><td>Чистая доменная функция</td></tr><tr><th scope=\"row\">Состояние</th><td>Актуальность и доступность ресурса</td><td>Ревизия уже изменилась</td><td>Репозиторий внутри согласованной операции</td></tr><tr><th scope=\"row\">Право</th><td>Разрешение на команду</td><td>Роль не может отменить заказ</td><td>Политика авторизации до изменения состояния</td></tr></tbody></table>\n<h2>Что именно описывает JSON Schema</h2>\n<p>Схема фиксирует структуру экземпляра: типы, обязательные ключи, диапазоны, шаблоны, перечисления и вложенные объекты. Она полезна как исполняемый контракт для одинаковой проверки разных потребителей и как документация, которую могут использовать инструменты. Но результат означает только соответствие экземпляра описанным ограничениям. Из него не следует, что заказ существует сейчас, пользователь имеет доступ или внешняя система согласилась на операцию.</p>\n<p>Слово «формат» требует осторожности. В JSON Schema Draft 2020-12 vocabulary <code>format</code> разделён на аннотацию и assertion: реализация может собирать информацию о формате, но не обязана отклонять экземпляр только из-за неё, если не включён режим проверки форматов. Поэтому для дат, URI и email нужно зафиксировать диалект, библиотеку и настройки. Если дата важна для домена, дополните схему явным правилом и тестом, а не рассчитывайте на одинаковое поведение всех валидаторов.</p>\n<p>Неизвестные поля требуют отдельного решения. Закрытый объект с <code>additionalProperties: false</code> ловит опечатку, но усложняет расширение контракта. Открытый объект легче развивать, но ошибочный ключ может быть молча проигнорирован потребителем. Выберите политику для конкретного endpoint, внесите её в схему и проверьте отрицательным примером. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12, но является его надмножеством с собственным диалектом; не переносите предположения о поведении одной реализации в другую.</p>\n<figure><img src=\"/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg\" alt=\"Матрица проверки команды: форма JSON, межполе́вой инвариант, состояние ресурса и право на действие\" loading=\"lazy\" /><figcaption>Один документ проходит несколько границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.</figcaption></figure>\n<h2>Самодостаточная проверка формы и инварианта</h2>\n<p>Ниже — запускаемый учебный фрагмент без HTTP-сервера, базы и внешних пакетов. Объект <code>filterSchema</code> показывает намерение контракта, а небольшая функция имитирует только нужный для примера набор проверок. Это не реализация JSON Schema и не замена библиотеке: цель фрагмента — сделать видимым порядок «форма → инвариант» и дать отрицательные случаи, которые можно воспроизвести командой <code>node validation-example.mjs</code>.</p>\n<pre><code>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');</code></pre>\n<p>Здесь сравнение дат безопасно только потому, что <code>isIsoDate</code> сначала проверяет календарную дату и приводит её к единому формату. Для более сложных календарей, часовых поясов и локального времени правило нужно заменить доменной библиотекой и тестами. В настоящем сервисе структурную часть передайте выбранному JSON Schema-валидатору, явно включите нужную проверку <code>format</code>, а функцию диапазона оставьте чистой и вызывайте после успешной проверки формы.</p>\n<h2>Состояние, права и гонка между чтением и записью</h2>\n<p>Проверка ревизии должна быть частью операции изменения, а не отдельным предварительным запросом. Если сначала прочитать заказ, затем проверить <code>revision</code> и только потом записать данные, второй клиент успеет изменить заказ между этими действиями. Получится классическая гонка: оба запроса увидели старое состояние, хотя применить можно было только один.</p>\n<p>Практический вариант — передавать ожидаемую ревизию и делать условное обновление внутри транзакции: обновить запись только при совпадении идентификатора и версии, увеличить версию атомарно, а отсутствие обновлённой строки превратить в конфликт. Точный SQL, блокировки и уровень изоляции зависят от СУБД. Поэтому пример с <code>getForUpdate</code> нельзя копировать без проверки драйвера, а тест должен запускать два конкурентных изменения, а не только вызывать функцию два раза подряд.</p>\n<p>Право — ещё один отдельный источник контекста. Ответ <code>403</code> говорит, что сервер понял запрос, но не разрешает действие этому субъекту; он не исправляется изменением <code>revision</code>. Если политика зависит от владельца, организации или состояния заказа, проверяйте её на том же представлении данных, для которого принимается решение. Не полагайтесь на скрытие кнопки в интерфейсе: клиент не является границей доверия.</p>\n<p>Для конфликта текущего ресурса RFC 9110 определяет <code>409 Conflict</code> и ожидает от сервера достаточно сведений, чтобы распознать источник конфликта. Для HTTP-precondition, заданного заголовками вроде <code>If-Match</code>, применяется отдельная семантика <code>412 Precondition Failed</code>. Выбранный статус должен соответствовать реальному контракту API, а тело — содержать стабильный код причины и безопасное действие: перечитать ресурс, объединить изменения или прекратить повтор.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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><th scope=\"row\">Валидатор принимает ключ, а клиент его не использует</th><td>Схема открыта или контракт не обновили</td><td>Сверить политику unknown keys и чтение поля</td><td>Закрыть объект либо документировать расширение</td></tr><tr><th scope=\"row\">Правильный JSON получает 400</th><td>Ошибка состояния скрыта под ошибкой формы</td><td>Разделить логи shape, invariant и state</td><td>Вернуть отдельный класс ошибки и исправить retry</td></tr><tr><th scope=\"row\">Повторный запрос меняет уже изменённый ресурс</th><td>Нет идемпотентности или проверки версии</td><td>Повторить команду при конкурентном обновлении</td><td>Добавить ключ идемпотентности или условие версии</td></tr><tr><th scope=\"row\">Тест схемы проходит, endpoint падает</th><td>Схема не покрывает runtime-ветку</td><td>Вызвать реальный handler с теми же данными</td><td>Добавить интеграционный тест на границе</td></tr><tr><th scope=\"row\">Клиент бесконечно повторяет запрос</th><td>Конфликт обозначен как временная ошибка</td><td>Проверить статус, код причины и retry policy</td><td>Различить повторяемый отказ и конфликт ресурса</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li>Назовите endpoint, метод, формат запроса и ожидаемые ответы. Не начинайте с общей функции <code>validate</code>: сначала определите документ и команду.</li><li>Выпишите типы, обязательность, enum, диапазоны, политику неизвестных ключей и диалект схемы. Сохраните положительный и отрицательный экземпляры.</li><li>Отделите правила, которым нужен только вход, от правил, которым нужны два поля, время, права или данные ресурса.</li><li>Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет, конфликт и временная недоступность не должны превращаться в один boolean.</li><li>Зафиксируйте поведение <code>format</code> в выбранном валидаторе. Один и тот же ключ может быть аннотацией в одной конфигурации и отклонением в другой.</li><li>Проверьте отрицательные пути: устаревшую ревизию, повтор команды, отсутствие ресурса, запрещённую роль и сетевой отказ. Для каждого укажите, должен ли клиент повторять запрос.</li><li>Запустите unit-тест чистой функции и интеграционный тест реального handler. Для конкурентного изменения создайте два запроса с одной ожидаемой ревизией и подтвердите, что успешен только допустимый.</li><li>Опишите безопасное расширение. При добавлении поля или значения enum проверьте старого потребителя и решите, нужен ли период совместимости.</li><li>Зафиксируйте критерий готовности: каждый класс отказа имеет тест, HTTP-статус, код причины, наблюдаемую запись и понятный следующий шаг клиента.</li></ol>\n<h2>Ограничения подхода</h2>\n<p>Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от нескольких систем и измениться между проверкой и записью. В таких случаях нужно описать границу согласованности и риск, а не расширять JSON Schema до роли универсального движка правил.</p>\n<p>HTTP-статус тоже не заменяет доменный контракт. <code>409</code> подходит для конфликта с текущим состоянием ресурса, но не обязан быть единственным выбором для каждого бизнес-отказа. <code>422</code> уместен, когда сервер понимает тип содержимого и не может обработать содержащиеся инструкции; конкретное применение согласуйте в API. Важна стабильная связь «причина → статус → действие», а не магическая цифра.</p>\n<p>Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, право проверяется сервером, а конкурентное изменение не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов, ошибок интеграции или неправильной бизнес-модели.</p>\n<h2>Критерий готовности</h2>\n<p>Решение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, политика авторизации отклоняет запрещённого субъекта, а атомарное изменение отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий проходит через тот же порядок. Если на любой вопрос команда отвечает только «валидатор всё проверит», граница ещё не проведена.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://json-schema.org/draft/2020-12\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Draft 2020-12</a> — официальная страница спецификации с документами Core, Validation и разделением vocabulary для <code>format</code>. Используйте её для формы JSON; состояние ресурса и права остаются за пределами схемы.</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 Draft 2020-12 и правил OpenAPI-диалекта. Сверяйте по нему границы переносимости между standalone JSON Schema и OpenAPI.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — стандарт HTTP с семантикой <code>409 Conflict</code>, <code>412 Precondition Failed</code> и <code>422 Unprocessable Content</code>. Применяйте его как опору для статусов, а не как замену доменному контракту.</li></ul>"
|
||
}
|