К списку уроков

Урок 6. Сеть: API, JSON, HTTP и fetch

Онлайн и в Б-3, 15 октября (четверг), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

Сеть: API, JSON, HTTP и fetch

Откуда приложение берёт данные

До этого занятия товары каталога хранились в массиве прямо в script.js. В настоящем магазине список товаров меняется каждый день: появляются новинки, заканчиваются остатки, меняются цены. Поэтому данные хранятся на сервере, а страница получает их по сети при загрузке.

На первом занятии мы разобрали, что браузер запрашивает у сервера HTML-документ. Кроме документов, сервер может отдавать и просто данные: список товаров, профиль пользователя, комментарии. В таком ответе нет разметки, только сами данные. Разметку из них строит JavaScript на странице, так же как функция рендера на прошлом занятии строила карточки из массива.

Приложение, которое получает данные с сервера, работает по такой схеме:

  1. Браузер загружает HTML, CSS и JavaScript страницы.

  2. Скрипт отправляет запрос на сервер за данными.

  3. Сервер отвечает данными в формате JSON.

  4. Скрипт сохраняет данные в состояние и вызывает рендер.

  5. Когда пользователь что-то меняет, например добавляет товар, скрипт отправляет на сервер новый запрос, а после ответа снова обновляет состояние и страницу.

Так устроены почти все современные веб-приложения. Мобильные приложения работают с теми же серверами и получают те же данные, просто показывают их по-своему.

На этом и следующем занятиях мы будем работать с учебным сервером DummyJSON. Он отдаёт список товаров и принимает запросы на создание, изменение и удаление, но на самом деле ничего не сохраняет. Во втором модуле вы будете работать с сервером, развёрнутым специально для курса.

Почитать ещё

API и эндпоинты

API (Application Programming Interface) — описание того, как программа может общаться с сервером: на какие адреса отправлять запросы, какие данные передавать и что придёт в ответ.

Отдельный адрес API, который выполняет одно действие, называют эндпоинтом. Вот несколько эндпоинтов DummyJSON:

GET     https://dummyjson.com/products              список товаров
GET     https://dummyjson.com/products/1            товар с id 1
GET     https://dummyjson.com/products/search?q=phone   поиск товаров
POST    https://dummyjson.com/products/add          создание товара
DELETE  https://dummyjson.com/products/1            удаление товара с id 1

Слово в начале строки — метод запроса, он говорит серверу, что нужно сделать. Методы разберём в разделе про HTTP.

Адреса устроены по общему принципу. /products означает коллекцию товаров, а /products/1 — один товар из этой коллекции. Такой стиль построения адресов называют REST, и большинство API устроены похоже.

Параметры запроса после знака ? уточняют, что именно нужно. У эндпоинта списка товаров DummyJSON есть параметры limit и skip: адрес /products?limit=12&skip=24 возвращает 12 товаров, пропустив первые 24. Названия параметров у каждого API свои, поэтому их узнают из документации.

Документация API перечисляет все эндпоинты, их параметры и формат ответов. Прежде чем писать код, откройте документацию и найдите нужный эндпоинт. У DummyJSON документация лежит на странице dummyjson.com/docs, а документацию API нашего курса мы откроем на двенадцатом занятии.

Почитать ещё

Формат JSON

Серверы обычно отправляют данные в формате JSON (JavaScript Object Notation). Это текстовый формат, похожий на запись объектов и массивов в JavaScript.

Так выглядит ответ DummyJSON на запрос двух товаров:

{
  "products": [
    {
      "id": 1,
      "title": "Essence Mascara Lash Princess",
      "price": 9.99,
      "thumbnail": "https://cdn.dummyjson.com/product-images/beauty/essence-mascara-lash-princess/thumbnail.webp"
    },
    {
      "id": 2,
      "title": "Eyeshadow Palette with Mirror",
      "price": 19.99,
      "thumbnail": "https://cdn.dummyjson.com/product-images/beauty/eyeshadow-palette-with-mirror/thumbnail.webp"
    }
  ],
  "total": 194,
  "skip": 0,
  "limit": 2
}

В JSON есть те же значения, что и в JavaScript: объекты, массивы, строки, числа, true, false и null. От записи объекта в JavaScript JSON отличается строгими правилами:

  • ключи объектов всегда в двойных кавычках;

  • строки только в двойных кавычках;

  • после последнего элемента массива или свойства объекта запятая не ставится;

  • нет комментариев, функций и значения undefined.

Первое, что нужно сделать с новым API, — посмотреть на форму ответа. GET-запрос можно отправить прямо из адресной строки браузера: откройте dummyjson.com/products?limit=2 и рассмотрите ответ. Обратите внимание, что массив товаров лежит не в корне ответа, а в свойстве products, а цены указаны в долларах.

Почитать ещё

JSON.parse и JSON.stringify

JSON передаётся по сети как текст. Чтобы работать с ним в JavaScript, текст превращают в объект, а чтобы отправить объект на сервер, его превращают в текст.

const text = '{"title": "Laptop 14", "price": 89990, "tags": ["учёба", "работа"]}';

const product = JSON.parse(text);
console.log(product.title);
console.log(product.tags[0]);

const body = JSON.stringify({ title: 'Мышь Click', price: 2490, inStock: true });
console.log(body);
console.log(typeof body);
  • JSON.parse(текст) разбирает строку с JSON и возвращает объект или массив.

  • JSON.stringify(значение) превращает объект или массив в строку JSON.

Если строка не соответствует правилам JSON, JSON.parse выбросит ошибку:

const broken = "{title: 'Laptop 14'}";
JSON.parse(broken);

В этом примере у ключа нет кавычек, а строка записана в одинарных кавычках. Для JavaScript это правильный объект, а для JSON — ошибка.

JSON.stringify пропускает свойства, в которых лежат функции или undefined. С сетевыми запросами эти функции нужны постоянно: JSON.stringify готовит данные для отправки на сервер, а разбор JSON из ответа за нас выполнит метод response.json(), о котором поговорим в разделе про fetch.

Почитать ещё

HTTP-запрос

Браузер и сервер обмениваются данными по протоколу HTTP. Каждое обращение к серверу — это пара из запроса и ответа.

Запрос на создание товара выглядит так:

POST /products/add HTTP/1.1
Host: dummyjson.com
Content-Type: application/json

{"title": "Наушники Studio", "price": 12490}

Запрос состоит из четырёх частей:

  1. Метод — POST. Он говорит серверу, какое действие нужно выполнить.

  2. Адрес — /products/add на сервере dummyjson.com.

  3. Заголовки — служебная информация о запросе в виде пар «имя: значение». Здесь заголовок Content-Type сообщает, что в теле запроса JSON.

  4. Тело — данные, которые отправляются на сервер. Тело идёт после пустой строки. У запросов, которые только получают данные, тела нет.

Такой текст браузер формирует сам, когда вы открываете страницу или отправляете запрос из JavaScript. Писать его руками не нужно, но нужно понимать, из чего он состоит: во вкладке Network инструментов разработчика запросы показаны именно по этим частям.

Почитать ещё

Методы HTTP

Метод запроса сообщает серверу, что нужно сделать с ресурсом по указанному адресу. Один и тот же адрес с разными методами означает разные действия: GET /products/1 возвращает товар, а DELETE /products/1 удаляет его.

Метод

Действие

Тело запроса

GET

Получить данные. Ничего не меняет на сервере

Нет

POST

Создать новый ресурс

Есть

PUT

Заменить ресурс целиком

Есть

PATCH

Изменить часть полей ресурса

Есть

DELETE

Удалить ресурс

Обычно нет

Значение методов — соглашение, которого придерживается большинство API. Сервер технически может удалять товар и по запросу GET, но так делать не принято: браузер и другие программы считают GET безопасным и могут повторить его сами, например при обновлении страницы.

Разница между PUT и PATCH видна на примере. Чтобы поменять у товара только цену, PATCH отправляет одно поле {"price": 79990}. PUT отправляет товар целиком, со всеми полями, и сервер заменяет старую версию новой.

Какие методы поддерживает конкретный эндпоинт, указано в документации API.

Почитать ещё

HTTP-ответ и статус-коды

Ответ сервера состоит из статус-кода, заголовков и тела.

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"id": 195, "title": "Наушники Studio", "price": 12490}

Статус-код — трёхзначное число, которое сообщает результат запроса. Первая цифра обозначает группу:

Группа

Значение

Частые коды

2xx

Запрос выполнен успешно

200 OK, 201 Created — ресурс создан, 204 No Content — успешно, но тела в ответе нет

3xx

Ресурс находится по другому адресу

301, 302 — браузер сам перейдёт на новый адрес

4xx

Ошибка в запросе

400 Bad Request — неправильный запрос, 401 Unauthorized — не удалось определить, кто отправил запрос, 403 Forbidden — доступ запрещён, 404 Not Found — ресурс не найден, 422 Unprocessable Content — данные не прошли проверку

5xx

Ошибка на сервере

500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

Разница между 4xx и 5xx важна для интерфейса. При ошибке 4xx пользователь может что-то исправить: заполнить обязательное поле или открыть существующий товар. При ошибке 5xx исправлять нечего, остаётся показать сообщение «Сервер временно недоступен» и предложить повторить позже.

Обычно в теле ответа с ошибкой сервер объясняет её причину. DummyJSON на запрос несуществующего товара отвечает статусом 404 и телом {"message": "Product with id '99999' not found"}. Такой текст можно показать пользователю или вывести в консоль при отладке.

Почитать ещё

Заголовки

Заголовки передают служебную информацию о запросе и ответе. Каждый заголовок — пара «имя: значение». В курсе вам чаще всего понадобятся три заголовка запроса.

  • Content-Type сообщает формат тела запроса. Для JSON значение application/json. Если заголовок не указать, сервер может не понять, что в теле, и проигнорировать данные.

  • Authorization передаёт данные, по которым сервер узнаёт пользователя, например токен. Некоторые API используют для этого свой заголовок. Сервер нашего курса во втором модуле будет узнавать вас по заголовку с персональным токеном.

  • Accept сообщает, в каком формате клиент хочет получить ответ.

В ответе сервера тоже есть заголовки. Заголовок Content-Type ответа сообщает формат тела: application/json для данных, text/html для страниц, image/webp для картинок.

Посмотреть заголовки любого запроса можно во вкладке Network: выберите запрос в списке и откройте вкладку Headers. Там отдельно показаны заголовки запроса и заголовки ответа.

Что будет, если забыть Content-Type, видно на примере DummyJSON. Запрос с заголовком возвращает созданный товар со всеми полями, а тот же запрос без заголовка возвращает только id: сервер не разобрал тело и создал пустой товар. Код такого запроса мы напишем в разделе об отправке данных.

Почитать ещё

CORS

Страница, открытая по адресу http://127.0.0.1:5500, отправляет запрос на https://dummyjson.com. Адрес страницы и адрес сервера отличаются, и такой запрос называют запросом на другой источник. Источник определяется протоколом, доменом и портом.

Браузер ограничивает такие запросы из соображений безопасности. Без ограничений любой сайт, который вы открыли, мог бы из скрипта отправить запрос в ваш интернет-банк и прочитать ответ, ведь браузер отправил бы запрос вместе с вашими данными для входа.

Механизм, который решает, можно ли странице прочитать ответ с другого источника, называется CORS (Cross-Origin Resource Sharing). Браузер отправляет запрос и смотрит на заголовок ответа Access-Control-Allow-Origin. Если в нём указан адрес страницы или символ *, скрипт получает ответ. Если заголовка нет, браузер не отдаёт ответ скрипту и пишет в консоль ошибку со словом CORS.

Для фронтенд-разработчика отсюда следует важное правило: ошибка CORS исправляется на сервере, а не в коде страницы. Сервер должен разрешить запросы с адреса вашего приложения. Публичные учебные API, в том числе DummyJSON и сервер курса, уже разрешают запросы с любых адресов.

Почитать ещё

fetch: первый запрос

Функция fetch отправляет HTTP-запрос из JavaScript. В самом простом случае ей передают адрес:

fetch('https://dummyjson.com/products?limit=3&select=title,price')
  .then((response) => response.json())
  .then((data) => {
    console.log(data.total);
    data.products.forEach((product) => {
      console.log(`${product.title}: $${product.price}`);
    });
  });

console.log('Запрос отправлен');

Параметр select в адресе — особенность DummyJSON: он просит сервер вернуть только перечисленные поля.

Запустите пример и посмотрите на порядок строк в консоли. Строка «Запрос отправлен» появилась первой, хотя в коде она последняя. Ответ идёт по сети какое-то время, и JavaScript не останавливается, чтобы его дождаться, а выполняет код дальше.

Поэтому fetch возвращает не данные, а промис — объект, который получит результат позже. Метод .then у промиса принимает функцию, которую нужно вызвать, когда результат будет готов. Подробно промисы разберём на следующем занятии, а пока запомните порядок шагов:

  1. fetch(адрес) отправляет запрос и возвращает промис.

  2. Первый .then получает объект ответа response. Когда он приходит, статус и заголовки уже известны, но тело ещё нужно дочитать. Метод response.json() дочитывает тело, разбирает JSON и тоже возвращает промис.

  3. Второй .then получает готовые данные — результат разбора JSON.

Если забыть вызвать response.json(), во второй .then попадёт объект ответа, а не данные, и data.products окажется undefined.

Почитать ещё

Статус ответа и ошибки

fetch считает запрос выполненным, если сервер вообще прислал ответ, даже со статусом 404 или 500. Проверять статус нужно самостоятельно.

fetch('https://dummyjson.com/products/99999')
  .then((response) => {
    console.log(response.status, response.ok);
    if (!response.ok) {
      throw new Error(`Сервер ответил статусом ${response.status}`);
    }
    return response.json();
  })
  .then((product) => {
    console.log('Товар:', product.title);
  })
  .catch((error) => {
    console.log('Не удалось загрузить товар.', error.message);
  });
  • response.status — статус-код ответа.

  • response.ok равно true, если статус от 200 до 299.

  • throw new Error(текст) создаёт ошибку и прерывает текущий шаг цепочки. Следующие .then пропускаются.

  • .catch в конце цепочки получает ошибку из любого шага выше.

В .catch попадают ошибки трёх видов: сеть недоступна и ответ не пришёл, сервер ответил статусом не из группы 2xx и мы сами выбросили ошибку, тело ответа оказалось не JSON и response.json() не смог его разобрать.

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

Почитать ещё

Отправка данных на сервер

Чтобы отправить запрос с другим методом или с телом, в fetch вторым аргументом передают объект настроек.

fetch('https://dummyjson.com/products/add', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Наушники Studio', price: 12490 }),
})
  .then((response) => {
    console.log('Статус:', response.status);
    return response.json();
  })
  .then((created) => {
    console.log('С заголовком:', created);
  });

fetch('https://dummyjson.com/products/add', {
  method: 'POST',
  body: JSON.stringify({ title: 'Наушники Studio', price: 12490 }),
})
  .then((response) => response.json())
  .then((created) => {
    console.log('Без заголовка:', created);
  });

Для запроса с телом нужны три настройки:

  • method — метод запроса. Если его не указать, fetch отправит GET.

  • headers — объект с заголовками. Для JSON в теле нужен Content-Type: application/json.

  • body — тело запроса в виде строки. Объект нужно превратить в строку через JSON.stringify: сам fetch этого не делает.

Второй запрос в примере отправлен без заголовка Content-Type, и сервер вернул товар без названия и цены.

Запрос на удаление обычно не содержит тела, достаточно указать метод:

fetch('https://dummyjson.com/products/1', { method: 'DELETE' })
  .then((response) => response.json())
  .then((deleted) => {
    console.log(deleted.title, deleted.isDeleted);
  });

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

Почитать ещё

Данные с сервера в состоянии страницы

Сетевой запрос встраивается в схему «состояние и рендер» с прошлого занятия. Появляется один новый шаг: данные приходят не сразу.

<section id="catalog" class="cards"></section>

<style>
  .cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 12px;
  }

  .card {
    padding: 8px;
    border: 1px solid #d0d7de;
    border-radius: 12px;
  }

  .card h3 {
    margin: 4px 0;
    font-size: 15px;
  }
</style>

<script>
  const API_URL = 'https://dummyjson.com';
  const catalog = document.querySelector('#catalog');
  let products = [];

  function toProduct(item) {
    return {
      id: item.id,
      title: item.title,
      price: item.price,
      image: item.thumbnail,
    };
  }

  function render() {
    catalog.innerHTML = products
      .map((product) => `
        <article class="card">
          <img src="${product.image}" alt="" />
          <h3>${product.title}</h3>
          <p>$${product.price}</p>
        </article>
      `)
      .join('');
  }

  function loadProducts() {
    fetch(`${API_URL}/products?limit=6`)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`Ошибка ${response.status}`);
        }
        return response.json();
      })
      .then((data) => {
        products = data.products.map(toProduct);
        render();
      })
      .catch((error) => {
        console.log('Не удалось загрузить товары:', error.message);
      });
  }

  loadProducts();
</script>
  • Адрес сервера хранится в константе API_URL. Если адрес поменяется, исправить нужно будет одну строку.

  • Функция loadProducts отправляет запрос, а после ответа кладёт товары в состояние и вызывает рендер.

  • Функция toProduct переводит объект из ответа сервера в форму, с которой работает страница. У сервера картинка называется thumbnail, а в нашем приложении — image. Если API поменяет названия полей, исправлять придётся только toProduct, а рендер и остальной код останутся прежними.

Пока ответ не пришёл, каталог пустой. На следующем занятии мы покажем на это время индикатор загрузки, а при ошибке — сообщение с кнопкой повтора.

Каждый запрос оформляют отдельной функцией с понятным именем: loadProducts, createProduct, removeProduct. Вызов fetch не пишут прямо в обработчиках событий и тем более в функции рендера: рендер вызывается часто, и каждый вызов отправлял бы новый запрос.

Почитать ещё

  • Дока: fetch()

Задания