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

Проверка ответа API во время выполнения

Теперь каталог получает данные из файла data/courses.json через fetch. Его содержимое может оказаться недоступным, иметь неправильный Content-Type, неверный синтаксис или неправильную схему. Назвать возвращаемое значение Course[] недостаточно: браузер действительно должен пройти каждую границу.

В этом уроке соединим HTTP-загрузку с Result, unknown и прежним runtime-парсером. Это статический локальный JSON, а не реализация серверного API ProfessorWeb. Форма и представление сохраняются из предыдущего шага. Независимый снимок lesson-20 есть в архиве продолжения; запросы и программа при подготовке не выполнялись.

Статус до разбора данных

Новый полный src/api.ts:

import { readCatalog } from './reader.js';
import type { Course } from './model.js';
import type { Result, CatalogError } from './result.js';

export async function loadCourses(url: string): Promise<Result<readonly Course[], CatalogError>> {
  let response: Response;
  try { response = await fetch(url, { headers: { Accept: 'application/json' } }); }
  catch { return { ok: false, error: { kind: 'network', message: 'Не удалось получить ответ' } }; }
  if (!response.ok) return { ok: false, error: { kind: 'http', status: response.status, message: `HTTP ${response.status}` } };
  const media = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase();
  if (media !== 'application/json') return { ok: false, error: { kind: 'media', message: 'Ожидался application/json' } };
  let source: string;
  try { source = await response.text(); }
  catch { return { ok: false, error: { kind: 'network', message: 'Не удалось прочитать тело ответа' } }; }
  return readCatalog(source);
}

Fetch может вернуть Response даже для 404. Поэтому ожидание самого Promise не является проверкой успешного статуса. Мы отдельно читаем response.ok и создаём ошибку http с числовым статусом. Неприемлемый HTTP-ответ не разбирается как успешный курс только потому, что его тело похоже на JSON.

После статуса проверяется media type. Функция разрешает application/json с возможным параметром, например charset, и приводит имя типа к нижнему регистру. Произвольные типы с суффиксом +json не объявлены допустимыми автоматически: такой набор потребовал бы отдельного договора конкретного API.

Сетевой отказ при fetch и отказ чтения тела разделены на две границы, но оба относятся к категории network. Затем текст передаётся readCatalog, который отделяет syntax и schema. Тип Promise переносит результат ожидания, а настоящие условия проверяют значения.

Разделение успешного ответа и HTTP-ошибки описано в Using Fetch. Наш загрузчик дополнительно выражает категории конкретной модели и сохраняет проверку каждой записи.

Один запрос и последнее успешное состояние

Полный src/app.ts:

import { loadCourses } from './api.js';
import { renderCourses } from './view.js';
import { readFilter, applyFilter } from './form.js';
import type { Course } from './model.js';

const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
const form = document.querySelector('#catalog-controls');
const fieldset = form?.querySelector('fieldset');
const reload = document.querySelector('#catalog-reload');
if (!(list instanceof HTMLUListElement) || !(status instanceof HTMLElement) ||
    !(form instanceof HTMLFormElement) || !(fieldset instanceof HTMLFieldSetElement) || !(reload instanceof HTMLButtonElement)) {
  throw new Error('Не найдены элементы каталога');
}
const ui = { list, status, form, fieldset, reload };
let accepted: readonly Course[] = [];
let ready = false;
let busy = false;

function showSelection(): void {
  renderCourses(ui.list, ui.status, applyFilter(accepted, readFilter(ui.form)));
}

async function reloadCatalog(): Promise<void> {
  if (busy) return;
  busy = true;
  ui.reload.disabled = true;
  ui.fieldset.disabled = true;
  ui.status.textContent = 'Загрузка';
  try {
    const result = await loadCourses('./data/courses.json');
    if (!result.ok) { ui.status.textContent = result.error.message; return; }
    accepted = result.value;
    ready = true;
    ui.fieldset.disabled = false;
    showSelection();
  } catch (error: unknown) {
    ui.status.textContent = error instanceof Error ? error.message : 'Непредвиденная ошибка';
  } finally {
    busy = false;
    ui.reload.disabled = false;
    ui.fieldset.disabled = !ready;
  }
}

ui.form.addEventListener('submit', event => {
  event.preventDefault();
  if (!ready || busy) return;
  try { showSelection(); }
  catch (error: unknown) { ui.status.textContent = error instanceof Error ? error.message : 'Ошибка формы'; }
});
ui.reload.addEventListener('click', () => { void reloadCatalog(); });
void reloadCatalog();

В HTML предыдущего урока fieldset теперь имеет disabled, а рядом добавлена кнопка catalog-reload с type="button". Полный документ включён в снимок. Данные JSON содержат те же четыре записи, что прежний data.ts, включая описание только первого курса. Импорт локального массива в app больше не определяет результат отображения.

Полный новый index.html; кнопка повторной загрузки находится вне отключаемого fieldset:

<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Каталог TypeScript</title><script type="module" src="./dist/app.js"></script></head>
<body><main><h1>Учебный каталог</h1>
<form id="catalog-controls"><fieldset disabled><legend>Отбор курсов</legend>
<label>Тема <select name="topic"><option value="all">Все темы</option><option value="frontend">Фронтенд</option><option value="publishing">Публикация</option></select></label>
<label>Не меньше уроков <input type="number" name="minLessons" value="0" min="0" step="1" required></label>
<button type="submit">Применить</button></fieldset></form>
<button id="catalog-reload" type="button">Загрузить заново</button>
<p id="catalog-status" role="status" aria-live="polite"></p><ul id="catalog-list"></ul>
</main></body></html>

Создайте data/courses.json с этим полным содержимым:

[
  {
    "id": "javascript",
    "title": "Современный JavaScript",
    "topic": "frontend",
    "lessons": 20,
    "url": "./courses/javascript.html",
    "description": "Основы языка и браузерного каталога"
  },
  {
    "id": "html-css",
    "title": "HTML и CSS",
    "topic": "frontend",
    "lessons": 16,
    "url": "./courses/html-css.html"
  },
  {
    "id": "performance",
    "title": "Производительность сайта",
    "topic": "frontend",
    "lessons": 12,
    "url": "./courses/performance.html"
  },
  {
    "id": "markdown",
    "title": "Статический сайт из Markdown",
    "topic": "publishing",
    "lessons": 12,
    "url": "./courses/markdown.html"
  }
]

busy не позволяет начать второй запрос, пока первый не завершился. Отключаются и кнопка, и отбор. После завершения кнопка доступна снова; полевая группа доступна только тогда, когда хотя бы один набор успешно принят. Это простой договор одной активной попытки, а не реализация отмены нескольких конкурентных запросов.

В ui сохранены уже проверенные конкретные элементы. Такой объект отделяет nullable-результаты поиска от ссылок, с которыми работают вложенные функции. Мы не заставляем каждый callback повторять начальную проверку шаблона и не полагаемся на небезопасные утверждения типа.

Ожидаемый начальный результат — четыре курса и 60 уроков. Фильтр frontend/16 затем даёт две записи и 36. Кнопка повторно получает JSON, сохраняя текущие значения формы. Загрузка сама не сбрасывает отбор в all: новый успешно принятый массив проходит прежнее выбранное условие.

Отказ не превращается в пустой успех

Если первый запрос неудачен, форма остаётся отключённой, а кнопка позволяет повторить попытку. Если повторный запрос неудачен после успешного, accepted сохраняет прежний проверенный массив. Карточки остаются последним успешным результатом, а status сообщает текущий отказ. После нового применения формы снова показывается отбор этих сохранённых данных.

Политика хранения здесь явная. Мы не подменяем ошибку пустым массивом: иначе человек увидел бы «Курсов: 0», хотя источник вообще не был прочитан. Корректный пустой JSON-массив, напротив, является успехом, поэтому ready становится true и форма остаётся работоспособной.

finally возвращает controls в согласованное состояние независимо от обычной ветки Result или неожиданного исключения. Если нарушена форма, сообщение объясняет отказ, а новый запрос не требует перезагрузки всего документа. Но catch не доказывает, что любое неожиданное исключение безопасно продолжать в сложном приложении; это ограниченный учебный компонент.

Пример схемы имеет настоящие условия

Файл src/reader.ts по-прежнему начинает с JSON.parse в unknown. Затем parseCourses проверяет массив, поля, две темы, положительное целое количество, ограниченную форму локального URL и уникальность id. Новые объекты состоят из разрешённых полей. Аннотация результата описывает следствие этих условий, а не заменяет их.

Для будущего ручного опыта можно отдельно изменить JSON: поставить строку вместо lessons, повторить id или удалить title. В таком случае ожидается schema, а прежние успешные данные не объявляются новым ответом. Незакрытая скобка относится к syntax. Отсутствующий файл относится к http, если сервер вернул обычный 404.

Содержимое неверного media type мы не пытаемся угадывать. Например, HTML-страница ошибки с 200 не должна попадать в JSON-модель. Такой ответ даст media прежде, чем разбирается текст. Если настоящий сервер имеет иной корректный договор, загрузчик нужно изменить по нему, а не молча игнорировать заголовок.

Когда полевая группа снова участвует в данных

После успешного ответа код устанавливает ready, включает fieldset и только затем вызывает showSelection. Порядок здесь существенен. Если оставить fieldset отключённым до finally, new FormData не включит topic и minLessons, а readFilter сообщит о неполном входе. Это описанное браузером поведение отключённых полей, а не особенность типов. См. Using FormData Objects.

Вызов showSelection синхронен: между включением группы и его завершением мы не добавили нового await. Busy остаётся true до finally и защищает прикладные обработчики. При успехе получается согласованный путь от принятого массива к текущему фильтру, а затем управление снова делает кнопку доступной.

Если форма была программно изменена на неправильную тему, успешный JSON всё равно остаётся проверенным набором accepted. Отказ чтения формы попадёт в общий catch, старые карточки могут остаться на экране, а исправление полей позволит применить новый принятый массив. Успешность данных и успешность выбранного представления являются разными условиями.

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

Вход не ограничен одной аннотацией

Проверка обязательных полей Course не устанавливает произвольные пределы размера ответа. Например, она не ограничивает длину title и число записей во всём массиве. Lessons проверяется как положительное целое, но дополнительные ограничения больших чисел и общей суммы потребуют собственной политики. Нельзя объявить весь внешний ответ безопасным для любых ресурсов только по наличию интерфейса.

Для большого настоящего каталога к договору обычно добавляют допустимый объём, пагинацию и правила длины текста. Их значения должны происходить из задачи продукта. Здесь включены четыре записи, поэтому урок сосредоточен на переходе HTTP → текст → unknown → проверенная модель. Расширять проверку следует в этой реальной границе, а не добавлением приведения у места отображения.

Локальная граница и перенос в продукт

Путь ./data/courses.json разрешается относительно документа, из которого выполнен fetch. Путь ссылки отдельного курса тоже относится к документу представления. Они не становятся автоматически относительными к src/api.ts. Поэтому в архиве data, courses и index находятся на согласованном уровне.

Сервера хранения, пагинации, авторизации и повторного сохранения здесь нет. Перенос на рабочий API потребует его настоящего формата и правил статусов. Наличие статически типизированного fetch-загрузчика не означает, что такой сервер уже построен.

Сумма 60 и отбор 2/36 остаются проверяемыми ожидаемыми результатами модели. При подготовке они выведены чтением, а не измерены браузером. В следующем уроке соберём небольшой публичный вход каталога, чтобы потребитель использовал согласованные операции и типы, сохраняя внутренние детали чтения за границей модулей.

Оглавление курса.