{ "index": 48, "slug": "editorial-2026-09-practice-frontend-backend-boundary", "title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API", "excerpt": "Успешный HTTP-ответ ещё не означает, что тело подходит экрану. Разбираем четыре проверки, отрицательные пути и безопасное поведение для 200, 202, 204 и ошибок API.", "contentHtml": "
Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.
\nЦена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии и будет выглядеть как случайный дефект кнопки.
\nГлавный вопрос — не «успешен ли запрос», а «можно ли этот ответ безопасно превратить в модель конкретного экрана». Код статуса, заголовок формата, синтаксис JSON и поля модели отвечают на разные вопросы. Клиент должен пройти их последовательно и иметь явный отрицательный путь для каждого шага.
\nHTTP 200 сообщает об успешной обработке запроса. Он не знает, какое состояние должен показать конкретный компонент и какие действия разрешены бизнес-правилом. Даже если в ответе есть JSON, это только синтаксически разобранное тело. JSON с полем {\"status\":\"done\",\"allowedActions\":\"edit\"} корректен как JSON, но может быть непригоден для экрана, который понимает только ready, pending и blocked.
Успешный транспортный ответ и пригодная screen model — разные утверждения. Первое проверяется статусом и доступностью ответа. Второе требует знания контракта потребителя: обязательных полей, типов, закрытых словарей и правила для неизвестного значения. Поэтому типы TypeScript, скомпилированные вместе с клиентом, не заменяют проверку данных, пришедших по сети.
\nГраница проходит в адаптере данных. Компонент получает проверенную модель или состояние ошибки с понятным кодом. Он не угадывает неизвестный статус, не подставляет пустой массив вместо сломанного allowedActions и не объявляет операцию завершённой только потому, что response.ok вернул true.
Порядок проверки важен: каждая следующая операция предполагает результат предыдущей. Сначала нужно понять, есть ли смысл читать тело. Затем — в каком формате его читать. Только после успешного разбора можно проверять поля.
\n| Уровень | Вопрос | Проверка | Отрицательный путь |
|---|---|---|---|
| Транспорт | Запрос принят сервером? | status, response.ok | классифицировать HTTP-ошибку |
| Наличие тела | Есть ли representation? | 204, 202 и заголовки ответа | перечитать ресурс или ждать статус |
| Формат | Как читать тело? | Content-Type | не запускать JSON parser вслепую |
| Модель | Подходит ли тело экрану? | типы, обязательные поля и словари | не передавать payload в render state |
Для 204 тело отсутствует по семантике HTTP. Вызов response.json() в таком пути не доказывает успех: он пытается разобрать пустой поток и обычно заканчивается ошибкой разбора. Ветку 204 нужно обработать раньше parser и вернуть результат вроде empty-success с действием refetch-screen, если экрану требуется актуальное состояние.
Заголовок Content-Type: application/json тоже не доказывает форму данных. Он говорит, как интерпретировать representation, но не гарантирует поля status или allowedActions. Для ошибки действует отдельный media type application/problem+json: его стоит разбирать как problem details, а не пытаться превращать в обычную модель экрана.
Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решения, и отдельно зафиксируйте, что делать при нарушении. Ниже — учебный контракт экрана заказа; названия статусов и команд проектные, их нельзя переносить в API без согласования.
\n| Поле | Допустимое значение | Решение UI | Что проверяет тест |
|---|---|---|---|
status | ready | pending | blocked | выбрать состояние экрана | неизвестное значение отклоняется |
allowedActions | массив известных команд | показать разрешённые кнопки | строка и неизвестная команда отклоняются |
messageCode | непустой код сообщения | выбрать локализованный текст | пустое или нестроковое поле отклоняется |
revision | неотрицательное целое | сравнить версию ресурса | устаревшее состояние не затирает новое |
Закрытый словарь статусов — это защита от тихого изменения API, а не запрет на эволюцию. Когда сервер вводит новый переход, меняются контракт, адаптер, визуальное состояние и тест. До этого момента безопаснее показать нейтральную ошибку и записать код нарушения, чем выбрать знакомый fallback.
\nАвторизация остаётся на сервере. Наличие edit в JSON может управлять видимостью кнопки, но не выдаёт право на операцию. Перед командой сервер заново проверяет права, актуальность версии и допустимость перехода. Клиентская screen model — описание отображения, не источник истины для доступа.
Следующий пример намеренно мал. Он показывает место границы, а не библиотеку валидации. Множества статусов и действий выбраны для демонстрации; в рабочем проекте их заменяют сгенерированный контракт, схема или ручной адаптер с такими же отрицательными путями.
\nconst 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}\nСначала проверяется response.ok, то есть диапазон успешных 2xx-ответов в Fetch API. Ошибка не смешивается с невалидной моделью: у неё свой kind и, если сервер прислал problem details, короткая диагностическая часть. Затем отдельно обрабатываются 204 и 202. В первом случае нет тела, во втором работа принята, но ещё не завершена.
Только обычный успешный ответ с ожидаемым media type доходит до response.json(). После parser запускается проверка полей. Компонент может отобразить screen-model-ready, а для других результатов выбрать безопасный экран, обновить ресурс или показать прикладную ошибку. Полный payload в логи отправлять не следует: он может содержать персональные и доменные данные.
Проверить функцию можно без браузера, подставив небольшие моки Response или объект с теми же свойствами. Набор тестов должен включать валидный JSON, неизвестный статус, строку вместо массива, пустое тело 204, 202 с Location, неверный media type и 422 с application/problem+json. Ожидаемый результат — конкретный kind, а не случайное исключение parser.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Экран показал «Готово», следующий клик падает | 200 приняли за модель | сравнить тело с runtime-схемой | запретить запись в state до валидации |
| DELETE вызывает ошибку parser | helper ждёт JSON после 204 | проверить статус до json() | вернуть empty-success и перечитать ресурс |
| Появилась кнопка неизвестной команды | UI доверяет свободной строке | проверить каждый элемент словарём | отклонить модель и не создавать кнопку |
| 422 превратился в общий toast | прикладной отказ назвали сетью | проверить media type и поля problem details | сопоставить machine-readable ошибку с полем |
| После timeout команда выполнилась дважды | клиент повторил запрос вслепую | проверить идемпотентность и статус операции | не повторять без гарантии; перечитать результат |
| Типы проходят, внешний сервис прислал другой JSON | тип сборки не проверяет сеть | сохранить фактический ответ и request id | добавить contract или runtime test |
Таблица задаёт порядок разговора на инциденте. Сначала фиксируется наблюдаемый ответ, затем проверяется слой, на котором возникло расхождение. Не стоит начинать с переписывания JSX: пока неизвестно, нарушены транспорт, формат или форма данных, изменение компонента может только спрятать причину.
\nQuery и command нельзя обрабатывать одинаково. Query получает representation и после проверки обновляет экран. Command просит сервер изменить состояние. Ответ 202 означает, что запрос принят в обработку, но она не завершена; клиенту нужен адрес проверки, идентификатор операции или другой явно описанный способ узнать результат. Не следует показывать финальное «Готово» на основании одного 202.
\nОтвет 204 означает успешное выполнение без дополнительного содержимого. После него экран обычно перечитывает ресурс, обновляет локальную модель по известному результату команды или закрывает экран — выбор зависит от контракта. Ни один из вариантов нельзя вывести из кода 204 без знания того, какая модель считается актуальной.
\nTimeout после отправки команды — неопределённый результат, а не доказательство неуспеха. Сервер мог применить операцию, пока клиент ждал. Для повторяемого запроса нужен idempotency key, идемпотентная семантика метода или способ проверить состояние. Для POST без такой защиты слепой retry может создать вторую операцию. Если API не предоставляет проверку результата, UI должен честно показать неопределённость и дать безопасный следующий шаг.
\nСтатусы 409, 422, 429 и 5xx сужают выбор, но не заменяют тело ошибки. 409 часто требует перечитать конфликтующий ресурс. 422 может содержать ошибки полей. Для 429 нужно учитывать лимит и, если он прислан, Retry-After. 5xx допускает повтор только при известной идемпотентности, ограниченном числе попыток и контроле нагрузки.
Problem details позволяют передать машинный тип проблемы, заголовок, подробность и расширения вроде указателя на поле. Клиент не обязан показывать пользователю сырые detail или instance: адаптер выбирает локализованный текст по безопасному коду, а диагностические поля отправляет в защищённый канал с ограниченным составом данных.
Кэш и конкуренция добавляют ещё один слой. Формально валидный status: ready может устареть через секунду. Для изменения состояния нужны версия ресурса, ETag или другой механизм, предусмотренный API. Runtime-валидация отвечает на вопрос «форма подходит?», но не на вопросы «данные свежие?» и «операция разрешена?».
Для воспроизводимости сохраните в тесте не только ожидаемый экран, но и входной HTTP-контекст: статус, media type, тело и способ обработки. Иначе тест может продолжать проверять старый мок с правильными типами, пока реальный сервис отдаёт другую representation.
\nЭтот подход не заменяет серверный контракт, авторизацию, бизнес-валидацию и проверку свежести. Он защищает границу от того, чтобы случайный payload стал UI-состоянием. Для сложных вложенных моделей ручная функция быстро станет второй схемой; используйте согласованный schema validator и измерьте стоимость на реальном размере ответа.
\nПроверка media type не гарантирует, что промежуточный proxy не изменил тело, а runtime-схема не гарантирует, что поле означает правильный бизнес-факт. Для независимых релизов нужны contract-тесты или тесты фактических ответов. Для асинхронных операций нужен API статуса, callback или другой документированный способ узнать завершение; один 202 этого не создаёт.
\nПример не описывает конкретную бизнес-модель и не даёт права копировать названия статусов. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект до бесконечности. Разделите read model, версионируйте ответ или добавьте адаптер на стороне потребителя.
\nГраница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Минимальный набор должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 202 не маскируется под завершение, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.
\nПроверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы, строит кнопку из свободной строки или показывает общий toast вместо различимой прикладной ошибки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки и с понятным следующим шагом.
\napplication/problem+json.response.ok, заголовков и чтения тела ответа в Fetch API.