8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 48,
|
||
"slug": "editorial-2026-09-practice-frontend-backend-boundary",
|
||
"title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API",
|
||
"excerpt": "Клиент может получить успешный HTTP-ответ и всё равно показать неверное состояние. Разбираем порядок проверок, runtime-контракт и отрицательные пути для screen model.",
|
||
"contentHtml": "<p>Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.</p>\n<p>Цена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии.</p>\n<p>Тезис статьи прост: статус HTTP сообщает результат обмена, но не доказывает, что тело подходит конкретному экрану. Клиент должен проверить HTTP, определить наличие representation, разобрать JSON, проверить минимальную screen model и только потом передать данные компоненту. Для каждого шага нужен отрицательный путь.</p>\n<h2>Что именно считается успехом</h2>\n<p>У ответа есть несколько уровней смысла. Код 200 сообщает, что сервер обработал запрос успешно на уровне операции. Заголовок <code>Content-Type</code> заявляет формат тела. JSON parser проверяет синтаксис. Runtime-валидатор проверяет поля, которые нужны экрану. Ни один предыдущий шаг не заменяет следующий.</p>\n<p>Тело <code>{\"status\":\"done\",\"allowedActions\":\"edit\"}</code> может быть корректным JSON. Но экран, который знает только <code>ready</code>, <code>pending</code> и <code>blocked</code>, не может безопасно выбрать состояние для <code>done</code>. Строка вместо массива также не становится моделью от того, что в ней записано знакомое слово.</p>\n<p>Граница принадлежит адаптеру данных, а не JSX-компоненту. Компонент получает проверенную модель или явный результат ошибки. Он не должен угадывать неизвестный статус, подставлять пустой массив и считать это подтверждённым состоянием.</p>\n<figure><img src=\"/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg\" alt=\"Поток проверки ответа: HTTP status, Content-Type, JSON parse, screen model и рендер; ошибка останавливает поток до компонента.\" loading=\"lazy\" /><figcaption>Проверки идут от транспорта к экрану. Ошибка на любом шаге останавливает передачу данных в render state.</figcaption></figure>\n<h2>Минимальная модель экрана</h2>\n<p>Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решение. Для экрана заказа это состояние, набор известных действий и код сообщения. У каждого поля есть тип, допустимые значения и действие при нарушении.</p>\n<div class=\"table-scroll\"><table><caption>Минимальная screen model для состояния заказа</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Допустимое значение</th><th scope=\"col\">Решение UI</th><th scope=\"col\">Отрицательный путь</th></tr></thead><tbody><tr><td><code>status</code></td><td><code>ready | pending | blocked</code></td><td>выбрать состояние экрана</td><td>остановить render</td></tr><tr><td><code>allowedActions</code></td><td>массив известных команд</td><td>показать разрешённые кнопки</td><td>не создавать кнопку</td></tr><tr><td><code>messageCode</code></td><td>непустая строка</td><td>выбрать локализованный текст</td><td>показать безопасную ошибку</td></tr><tr><td>HTTP status</td><td>статус операции</td><td>отделить успех от отказа</td><td>не строить модель только по числу</td></tr></tbody></table></div>\n<p>Словарь статусов должен быть закрытым, пока команда не описала новый переход. Это не запрет на развитие API. Новый статус должен менять контракт, обработчик и тест. Молчаливый fallback скрывает изменение API и превращает его в случайный UI-дефект.</p>\n<h2>Рабочий пример: проверяем ответ до render</h2>\n<p>Ниже учебный пример на JavaScript. Списки статусов и действий выбраны для демонстрации. Они не описывают production API и не обещают production-результат. В реальном проекте их заменяет контракт конкретного endpoint.</p>\n<pre><code>const statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\nfunction validateScreenModel(value) {\n const errors = [];\n if (!value || typeof value !== 'object' || Array.isArray(value)) return { valid: false, errors: ['body-must-be-object'] };\n if (!statuses.has(value.status)) errors.push('status-is-unknown');\n if (!Array.isArray(value.allowedActions)) errors.push('allowedActions-must-be-array');\n else if (value.allowedActions.some((item) => !actions.has(item))) errors.push('allowedActions-contains-unknown-action');\n if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) errors.push('messageCode-must-be-non-empty');\n return { valid: errors.length === 0, errors };\n}\nasync function readScreen(response) {\n if (response.status === 204) return { kind: 'empty-success', next: 'refetch-screen' };\n if (!response.ok) return { kind: 'http-error', status: response.status };\n if (!response.headers.get('content-type')?.includes('application/json')) return { kind: 'wrong-media-type' };\n let body;\n try { body = await response.json(); } catch { return { kind: 'invalid-json' }; }\n const result = validateScreenModel(body);\n return result.valid ? { kind: 'screen-model-ready', model: body } : { kind: 'invalid-screen-model', errors: result.errors };\n}</code></pre>\n<p>Функция возвращает классификацию, а не случайную строку из parser. Такой результат связывается с error boundary, логом и безопасным состоянием компонента. В технический канал передавайте код нарушения и request id. Не отправляйте весь payload: он может содержать персональные или доменные данные.</p>\n<p>Порядок проверок важен. Для 204 нельзя вызывать <code>response.json()</code>: успешное выполнение не означает наличие representation. Для неверного <code>Content-Type</code> повтор запроса обычно не исправит формат. Для невалидной модели retry тоже не является решением: сервер может стабильно возвращать тот же payload.</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>Экран показал «Готово», следующий клик падает</td><td>200 приняли за модель</td><td>сравнить тело с runtime-схемой</td><td>запретить запись в state до валидации</td></tr><tr><td>DELETE вызывает ошибку parser</td><td>helper ждёт JSON после 204</td><td>проверить статус до <code>json()</code></td><td>вернуть empty-success и перечитать ресурс</td></tr><tr><td>Появилась кнопка неизвестной команды</td><td>UI доверяет строке action</td><td>проверить каждый элемент словарём</td><td>отклонить модель и не создавать кнопку</td></tr><tr><td>422 превратился в общий toast</td><td>прикладной отказ назвали сетью</td><td>прочитать machine code и поле</td><td>подсветить поле, если контракт это разрешает</td></tr><tr><td>После timeout команда выполнилась дважды</td><td>клиент повторил запрос вслепую</td><td>проверить idempotency key и статус операции</td><td>не повторять; перечитать результат</td></tr><tr><td>Типы проходят, внешний сервис прислал другой JSON</td><td>тип сборки не проверяет сеть</td><td>воспроизвести фактический ответ</td><td>добавить contract или runtime test</td></tr></tbody></table></div>\n<h2>Query, command и пустой результат</h2>\n<p>Чтение и изменение состояния требуют разных правил. Query получает representation и обновляет экран после проверки. Command просит сервер изменить состояние. Ответ 202 означает принятие асинхронной работы, а не завершённый переход. Ответ 204 означает успешную операцию без тела. В обоих случаях клиенту нужен путь к актуальной модели.</p>\n<p>Не превращайте ответ команды в локальный флаг <code>isSuccess</code>. Если запрос оборвался после отправки, клиент не знает, успел ли сервер изменить ресурс. Повтор POST может создать вторую операцию. Безопасный вариант зависит от API: idempotency key, endpoint статуса или повторное чтение. Если контракт ничего не даёт, UI не может честно обещать результат после timeout.</p>\n<p>Ошибки 409, 422, 429 и 5xx нельзя свести к одному toast. 409 может требовать перечитать конфликтующий ресурс. 422 может вернуть ошибки полей. 429 может содержать ограничение частоты и время следующей попытки. 5xx допускает повтор только при известной идемпотентности и ограниченном бюджете. Код статуса сужает выбор, но не заменяет error detail и правило следующего действия.</p>\n<h2>Как внедрить границу</h2>\n<ol><li>Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.</li><li>Зафиксируйте intent: query, command или получение результата фоновой операции.</li><li>Выпишите минимальную screen model и закройте словари статусов, действий и кодов сообщений.</li><li>Разделите обработку HTTP, media type, JSON parse и runtime validation. Для 204 задайте результат без тела.</li><li>Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx и timeout.</li><li>Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело и неверный media type.</li><li>Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.</li><li>Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.</li></ol>\n<h2>Ограничения</h2>\n<p>Runtime-валидатор проверяет форму данных, но не доказывает бизнес-истину. <code>status: ready</code> может быть формально корректным и устареть через секунду. Для этого нужны версия ресурса, контроль конкуренции, кэш-политика или повторное чтение. Валидация не заменяет authorization: наличие действия в JSON не выдаёт право на серверную операцию.</p>\n<p>OpenAPI и сгенерированные TypeScript-типы описывают договорённость, но тип на этапе сборки не проверяет байты из сети. При независимом выпуске сервисов оставьте тест фактического ответа или runtime-схему. Ручная функция подходит для маленькой модели. Для сложных вложенных структур используйте schema validator и измерьте стоимость на реальном размере payload.</p>\n<p>Учебный пример не описывает конкретную бизнес-модель. Он показывает место проверки и решения, которые нужно подтвердить контрактом проекта. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект бесконечно. Назовите отдельные read model или версионируйте ответ.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.</p>\n<p>Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы или строит кнопку из свободной строки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — официальная спецификация семантики методов, представлений и статус-кодов HTTP.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9457: Problem Details for HTTP APIs</a> — официальный формат машинно-читаемых деталей ошибки для HTTP API.</li><li><a href=\"https://spec.openapis.org/oas/latest.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification</a> — официальное описание операций HTTP API и вариантов ответов.</li></ul>"
|
||
}
|