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