Перейти к содержанию

Контракт API и проверка ответа

Оглавление серии «Vue 3: кабинет сотрудников «Команда»»

Общая коллекция связывает экраны, но пока хранится только в памяти. Подготовим чтение сотрудников через API. Результат урока — отдельный транспортный модуль с проверкой внешнего ответа и письменный договор сервера. Сами экраны этого шага ещё используют локальные записи; подключение загрузки появится следующим изменением. Так мы отдельно рассмотрим, какие данные допускаются в модель, и когда их следует заменить.

Для «Команды» договор чтения прост: GET /api/members возвращает объект с массивом data. Каждый участник имеет строковые id, name, role, булево available и положительную целую version. У сервера есть текущий пользователь и собственные права на просмотр. Мы не выдумываем сервер в архиве: контракт требует реализации на том же origin, и без неё будущая загрузка не даст заявленного результата.

Проверка данных на границе

Ниже полный новый src/api/team.js. Отдельный класс различает HTTP-отказ, сетевой сбой и нарушение ожидаемого формата. Проверка создаёт свежий объект из разрешённых полей, поэтому непредусмотренные значения сервера не попадают в состояние автоматически.

export class ApiError extends Error {
  constructor(message, status = 0, data = null) {
    super(message); this.name = 'ApiError'; this.status = status; this.data = data
  }
}
export function parseMember(value) {
  if (!value || typeof value.id !== 'string' || !value.id ||
    typeof value.name !== 'string' || !value.name.trim() ||
    typeof value.role !== 'string' || !value.role.trim() ||
    typeof value.available !== 'boolean' ||
    !Number.isSafeInteger(value.version) || value.version < 1) {
    throw new ApiError('Сервер вернул неверную карточку участника.')
  }
  return { id: value.id, name: value.name, role: value.role,
    available: value.available, version: value.version }
}
async function jsonResponse(response) {
  let body = null
  try { body = await response.json() } catch { /* handled below */ }
  if (!response.ok) throw new ApiError('Запрос отклонён сервером.', response.status, body)
  if (body === null) throw new ApiError('Ожидался JSON-ответ.', response.status)
  return body
}
export async function listMembers(signal) {
  const response = await fetch('/api/members', {
    signal, credentials: 'same-origin', headers: { Accept: 'application/json' },
  })
  const body = await jsonResponse(response)
  if (!Array.isArray(body.data)) throw new ApiError('Ожидался массив data.')
  const members = body.data.map(parseMember)
  if (new Set(members.map(member => member.id)).size !== members.length)
    throw new ApiError('Идентификаторы участников повторяются.')
  return members
}

fetch не отклоняет Promise только потому, что HTTP ответ имеет статус 404 или 500. Код проверяет response.ok отдельно. Руководство Fetch описывает это различие. Для кабинета важно не перепутать отсутствие JSON после страницы входа с пустой командой: пользователь должен получить объяснимый отказ, а не успешный экран без сотрудников.

Полный CONTRACT.md добавляется к исходникам. Это документ, а не выполняемый сервер.

# Серверный договор «Команды»
Same-origin session API. Сервер реализуется отдельно; в архив не включён.
GET /api/members -> 200 {"data":[{"id":"m-1","name":"Анна","role":"Дизайнер","available":true,"version":1}]}
Массив полный, id уникальны. Частные поля в ответ не включаются.
PATCH /api/members/{id} принимает {"available":false,"version":1}.
Запись требует действующей сессии и заголовка X-CSRF-TOKEN.
HTML приложения содержит meta[name="csrf-token"] с токеном текущей сессии.
Сервер атомарно сравнивает version, проверяет право и обновляет версию.
200 {"data":{...пять полей участника...}} означает подтверждённую запись.
409 {"current":{...пять полей участника...}} означает конфликт версии без записи.
422 {"errors":{"available":["..."]}} означает отказ валидации.
401 требует входа; 403 означает запрет; 404 означает отсутствие записи.
5xx и сетевой сбой не доказывают, что операция не выполнилась.
GET не изменяет доступность. Logout завершает серверную сессию.

Что гарантирует схема

parseMember проверяет минимальные требования клиента. Если available приходит строкой "false", она не проходит как булево значение. Без этого строка оказалась бы truthy и карточка могла показать доступность вместо занятости. Полезный неправильный случай демонстрирует, зачем проверять внешний ответ даже в JavaScript-проекте без TypeScript. Статическая типизация сама по себе также не проверила бы содержимое JSON во время работы.

Второе правило относится к уникальности id. Даже правильные по отдельности карточки могут образовать неверный список, если два объекта имеют один ключ. Мы отклоняем весь ответ, поскольку иначе router и ключи DOM не смогут однозначно определить участника. Можно было бы выбрать стратегию объединения или последней записи, но она скрыла бы серверную ошибку. Для кабинета такой ремонт без договорённости опаснее понятного сообщения.

Проверка не доказывает добросовестность сервера и не заменяет авторизацию. Она лишь предотвращает нарушение клиентской модели. Сервер обязан ограничить массив текущим пользователем, не включать секретные поля и запретить чужую запись. Сам фронтенд не может установить это правило через список allowed полей, ведь лишние данные уже были бы доставлены в браузер. Поэтому документ и клиентский код описывают разные стороны общей границы.

credentials same-origin разрешает обычные cookies того же origin. Это не настройка произвольного внешнего домена и не обход CORS. Если приложение и API разместить раздельно, понадобятся другой договор cookies, CORS и защиты запроса. Нельзя изменить один флаг клиента и считать задачу входа решённой. В этом курсе выбран один origin, что уменьшает число инфраструктурных предположений и позволяет сосредоточиться на состояниях интерфейса.

Место отказа и сообщение пользователю

ApiError содержит status и data, но пользователь не должен автоматически видеть необработанное тело ответа. Оно может быть техническим, слишком длинным или содержать сведения, не предназначенные для экрана. В следующих уроках интерфейс выберет предметные сообщения по статусу. Детали валидации также нужно сопоставлять с известными полями формы, а не выводить как произвольную HTML-строку.

При ошибке формата нельзя частично заменить общую команду уже обработанными строками. Весь массив создаётся до передачи наружу; только успешное завершение функции даёт готовый результат. Это помогает сохранить согласованность источника. Когда загрузка станет асинхронной, прежние данные могут оставаться видимыми с отдельной ошибкой обновления. Такое поведение потребует ещё и состояния запроса, которого транспортный модуль не должен смешивать с отображением.

Перед подключением сети прочитайте договор как автор отдельного сервера. Определены ли форма ответа, права, версия и значение конфликта? Что произойдёт при странице HTML вместо JSON? Почему пустой массив отличается от отказа? Эти вопросы проверяют полноту границы лучше, чем обещание «API будет работать». Следующий урок вызовет listMembers из общего состояния и защитит его от запоздалого результата отменённой загрузки.

Минимальный ответ и развитие договора

Пример ответа в CONTRACT показывает одного участника, но клиент допускает любое число записей, включая ноль. Ограничение полного массива важно для локального поиска: если сервер позже введёт страницы, прежний интерфейс перестанет означать поиск по всей команде. Тогда потребуется новый договор данных, указание общего числа и передача поискового условия серверу. Нельзя просто заменить массив первой страницей, сохранив подпись и обещание текущего поиска.

Объект data сохраняет место для будущих метаданных, но клиент не должен угадывать их смысл. Поле total, если оно появится, не обязательно равно длине data. В нынешней модели такого поля нет, поэтому число сотрудников берётся из фактически принятой полной коллекции. Отдельный номер version относится к каждой записи и не описывает версию всего ответа или версию маршрута API.

Неизвестные дополнительные поля отбрасываются, а известные неправильного типа отклоняются. Это осознанная разница. Новая безвредная метка сервера не ломает чтение, но отсутствие доступности ломает смысл карточки. Если приложение требует строгого отклонения любого лишнего поля, проверка должна прямо выразить это правило. Наш вариант ограничивает выход модели, не обещая закрытую схему всех внешних свойств.

Наконец, доступный HTTP-ответ может прийти после завершения пользовательской сессии из кеша или промежуточного слоя. Сервер должен настроить работу с частными данными соответствующим образом. Клиентская проверка формы JSON не доказывает свежесть или право использования кешированного ответа. Договор текущего пользователя остаётся серверной обязанностью на каждом чтении.

Для самостоятельного воспроизведения результата «Отделить транспорт от модели участника» выберите lesson-12 в архиве исходников. Статус выполнения этого состояния: not_run.