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

Чтение материалов через REST API

Курсы теперь являются отдельными объектами с числовым полем pw_lessons. Прочитаем их вне шаблона темы через REST API. Нам нужен небольшой список, который получает публичные данные и не использует административную сессию. Результат урока — чёткий контракт ответа и клиент, различающий отсутствие результата, неправильный формат и отказ запроса. Изменение содержимого через API здесь не выполняем и не реализуем.

REST-представление отделено от HTML-страницы курса. Адрес /courses/javascript/ показывает блоковый текст через тему, а /wp-json/wp/v2/courses возвращает данные типа с rest_base: courses. Публичная запись доступна для чтения согласно возможностям WordPress; черновики не становятся публичными от регистрации API. Аутентификация изменения данных имеет отдельные условия, описанные в руководстве REST. Наш клиент использует только GET и намеренно не передаёт cookies.

Выбор полей и простой текст

У стандартного title есть представление rendered, которое не следует произвольно вставлять через innerHTML. Для учебного списка зарегистрируем своё строковое поле pw_label, без разметки. Добавьте следующий полный фрагмент в Tools после метаданных. Его реализация читает заголовок объекта, удаляет теги и декодирует сущности; клиент потом выводит полученную строку через textContent.

function pw_workshop_register_course_label(): void {
    register_rest_field('pw_course', 'pw_label', [
        'get_callback' => static function (array $object): string {
            $title = get_the_title((int) $object['id']);
            return html_entity_decode(wp_strip_all_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        },
        'schema' => [
            'description' => 'Plain text course title',
            'type' => 'string',
            'context' => ['view', 'edit'],
            'readonly' => true,
        ],
    ]);
}
add_action('rest_api_init', 'pw_workshop_register_course_label');

Поле является производным представлением, а не второй редактируемой копией названия. Callback выполняется в подготовке ответа и не сохраняет строку в базе. У него нет сетевой зависимости. Произвольные фильтры заголовка сторонних плагинов могут изменить источник, поэтому этот контракт относится к нашей минимальной установке. Для сложного проекта нужно решить, какое именно название считается авторитетным: исходный заголовок либо результат штатных фильтров WordPress.

Параметр _fields сокращает выбранные поля ответа; документация глобальных параметров объясняет его назначение. Он не является механизмом ограничения прав: если поле публичное, другой клиент может запросить его иначе. Для нашей карточки безопасны ID, слаг, простое название, количество уроков и публичная ссылка. Не добавляем персональные сведения преподавателей или скрытые редакционные примечания, надеясь, что клиент не включит их в свою выборку.

Полный клиент одной страницы списка

В архиве reader/index.html содержит заголовок, пустой список <ul id="courses"></ul>, область <p id="status" role="status"></p> и подключение reader.js как module. Файл располагается на том же origin в отдельном учебном сценарии; он не входит в тему и не публикуется автоматически. Ниже полный reader.js, который запрашивает первую страницу из двух курсов:

const list = document.querySelector('#courses');
const status = document.querySelector('#status');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const url = new URL('/wp-json/wp/v2/courses', location.origin);
url.search = new URLSearchParams({
  per_page: '2', page: '1', orderby: 'id', order: 'asc',
  _fields: 'id,slug,pw_label,meta.pw_lessons,link'
}).toString();
try {
  const response = await fetch(url, {credentials: 'omit', signal: controller.signal});
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const rows = await response.json();
  if (!Array.isArray(rows)) throw new Error('Ответ не является списком');
  const fragment = document.createDocumentFragment();
  for (const row of rows) {
    if (typeof row.pw_label !== 'string' || typeof row.link !== 'string' ||
        !Number.isInteger(row.meta?.pw_lessons) || row.meta.pw_lessons < 0 || row.meta.pw_lessons > 500) {
      throw new Error('Неверная карточка курса');
    }
    const target = new URL(row.link, location.origin);
    if (!['https:', 'http:'].includes(target.protocol) || target.origin !== location.origin) throw new Error('Недопустимая ссылка');
    const item = document.createElement('li');
    const link = document.createElement('a');
    link.href = target.href;
    link.textContent = `${row.pw_label}: ${row.meta.pw_lessons} уроков`;
    item.append(link);
    fragment.append(item);
  }
  list.replaceChildren(fragment);
  status.textContent = rows.length ? 'Показана первая страница каталога.' : 'Курсов пока нет.';
} catch (error) {
  status.textContent = error.name === 'AbortError' ? 'Запрос не завершился вовремя.' : 'Не удалось прочитать каталог.';
} finally {
  clearTimeout(timeout);
}

Временный DocumentFragment не меняет видимый список, пока все строки не прошли проверку. Если вторая карточка неправильная, не показываем первую как будто ответ полностью пригоден. Это не универсальная схема JSON, а небольшой контракт наших полей. В production также потребуются ограничения размера ответа и другие правила клиента; количество строк WordPress ограничивается серверными параметрами, но произвольный внешний ответ нельзя считать безопасным только из-за массива.

Для чтения выбираем тот же origin, потому что клиент является отдельной страницей нашей учебной установки. Если разместить его на другом хосте, относительный путь начнёт обращаться к другому серверу. Требуются явный базовый адрес и согласованные правила CORS. Разрешение CORS не выдаёт права на редактирование и не является способом аутентифицировать пользователя. В этой серии не вводим внешний клиент с административными секретами и не решаем проблему включением произвольного origin для всех операций.

credentials: omit означает, что публичный запрос не рассчитывает на вход редактора. Сравните его с запросом, который браузер отправлял бы вместе с cookies: редакторский доступ может скрыть ошибку публичной модели. Для записи в REST WordPress использует иной договор; при cookie-аутентификации также нужен соответствующий REST nonce. Токен редакторского metabox из прошлого урока не является универсальным токеном любого REST-запроса. Мы специально не добавляем POST в этот клиент, чтобы не смешивать эти механизмы.

Дополнительное поле pw_label не отменяет стандартное title. Другие клиенты могут продолжать использовать штатный ответ. Изменение существующей схемы ради одного нового интерфейса создало бы ненужный риск совместимости. Наш список выбирает производное текстовое поле осознанно, а HTML-страница продолжает использовать блок Post Title. Если заголовок содержит амперсанд или буквальные угловые скобки, итоговый textContent должен показать текст без появления новых элементов. Этот случай следует добавить в самостоятельную проверку преобразования, поскольку четыре простых исходных названия не раскрывают всю границу представления.

Пагинация и наблюдаемый результат

При создании курсов в порядке урока 17 первая страница с сортировкой ID по возрастанию ожидаемо содержит JavaScript 20 и HTML и CSS 16. Измените page на 2: ожидаются Markdown 12 и производительность 12. ID назначены конкретной базой, поэтому число «первый» не означает ID 1. Размер и номер страницы влияют на отдельный запрос. Полный каталог нельзя обещать читателю по ответу, где специально выбраны только два объекта.

WordPress использует заголовки X-WP-Total и X-WP-TotalPages для коллекций; это описано в руководстве пагинации. В нашем клиенте нет кнопки следующей страницы и автоматического прохода всех страниц. Это намеренный результат одного запроса, а не готовый бесконечный каталог. Для дальнейшего расширения нужно читать заголовки, согласовать состояние страницы и различать пустую коллекцию с ошибкой недопустимого номера.

Попробуйте запросить недопустимое значение параметра, например per_page=0. Ожидается ошибка API, которую клиент покажет как отказ чтения, а не текст «Курсов пока нет». При отсутствии сети сработает другая причина отказа; timeout является только нашим пятиисекундным бюджетом, не гарантией времени работы WordPress. Отмена ожидания не доказывает, что сервер немедленно прекратил обработку. Операции здесь только читающие, поэтому повтор не создаёт новую карточку.

HTML-файл, PHP и запросы при подготовке не выполнялись. REST-ответы и заголовки считаются ожидаемыми и требуют проверки на отдельном стенде с опубликованными курсами. После успешного чтения важно сохранить одну границу: API передаёт редакционное число, а не проверяет существование соответствующих уроков. Следующий шаг вернёт нас к обычному сайту и согласует поиск по записям и карточкам, не делая его копией этого клиентского списка.

Оглавление серии