Соберём API учебного каталога: список товаров, отдельную карточку и понятные ошибки запроса. Браузер получит JSON, сервер выберет маршрут и статус, а вы сможете проверить контракт командой curl и небольшой страницей.

Начнём с интерфейса API, затем напишем сервер на встроенном модуле Node.js http. Сторонний фреймворк для этого упражнения не нужен. Все товары вымышленные, данные хранятся в памяти и не меняются между запросами.

Понадобятся установленный современный Node.js и база JavaScript: функции, массивы, объекты, модули и обработка ошибок. Если запуск файла пока вызывает вопросы, воспользуйтесь разбором запуска JavaScript через Node.js. Наша задача начинается после установки среды.

Зафиксируйте ответы до написания маршрутов

Клиенту полезно заранее знать, где лежит список, как открыть товар и что означает отказ. Рекомендуем записывать этот контракт до реализации: иначе одинаковые данные начинают возвращаться в разных формах, а интерфейс заполняется догадками.

В учебном API будет следующий договор:

ЗапросСтатусТело ответа
GET /api/products200Объект с items, total и limit
GET /api/products?limit=1200Один товар, общее число товаров
GET /api/products/1200Объект с product
GET /api/products/999404error с кодом PRODUCT_NOT_FOUND
GET /api/products/abc400error с кодом INVALID_ID
GET /api/products?limit=0400error с кодом INVALID_LIMIT
POST /api/products405error с кодом METHOD_NOT_ALLOWED

Поле items всегда содержит массив, даже когда в нём один товар или нет товаров. Деталь возвращает product. Структуры отличаются намеренно: клиент видит, какой ответ получил. Число total относится ко всему учебному каталогу, а limit ограничивает количество элементов ответа.

Идентификатор товара — положительное целое число, записанное цифрами без ведущих нулей. Параметр limit принимает значения от одного до 20; по умолчанию возвращаем не больше 10 товаров. Эти ограничения принадлежат нашему контракту, а не самому Node.js.

HTTP-статус отражает исход запроса. Значение 400 выбрано для неверного параметра, 404 — для отсутствующего ресурса, 405 — для неподдерживаемого метода. Семантика этих статусов описана в разделе RFC 9110 об ответах клиента. Код внутри JSON уточняет ситуацию для приложения.

Напишите сервер в отдельной папке

Создайте папку catalog-api, а в ней server.mjs. Рядом позже появится index.html. Сервер прочитает страницу при запуске, поэтому сохраните оба файла перед командой node server.mjs.

Встроенный createServer вызывает обработчик для HTTP-запроса. Объект req несёт метод и адрес, а res позволяет задать статус, заголовки и тело ответа. Эти API описаны в документации Node.js HTTP.

Ниже сервер целиком. В нём отдельная функция для JSON-ответа; маршруты вызывают её и сразу завершают обработку.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const html = await readFile(new URL('./index.html', import.meta.url));
const products = [
  { id: 1, name: 'Учебная лампа', price: 159900, currency: 'RUB' },
  { id: 2, name: 'Учебный блокнот', price: 29900, currency: 'RUB' },
];

createServer((req, res) => {
  function send(status, data, headers = {}) {
    res.writeHead(status, {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'no-store',
      ...headers,
    });
    res.end(req.method === 'HEAD' ? undefined : JSON.stringify(data));
  }
  function fail(status, code, message, headers) {
    send(status, { error: { code, message } }, headers);
  }

  try {
    const url = new URL(req.url, 'http://127.0.0.1:3000');
    if (!['GET', 'HEAD'].includes(req.method)) {
      fail(405, 'METHOD_NOT_ALLOWED', 'Используйте GET или HEAD.', {
        Allow: 'GET, HEAD',
      });
      return;
    }
    if (url.pathname === '/') {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(req.method === 'HEAD' ? undefined : html);
      return;
    }
    if (url.pathname === '/api/products') {
      const raw = url.searchParams.get('limit');
      const limit = raw === null ? 10 : Number(raw);
      if (raw !== null && (!/^[1-9]\d*$/.test(raw) || limit > 20)) {
        fail(400, 'INVALID_LIMIT', 'limit должен быть целым числом от 1 до 20.');
        return;
      }
      send(200, { items: products.slice(0, limit), total: products.length, limit });
      return;
    }
    const detail = url.pathname.match(/^\/api\/products\/([^/]+)$/);
    if (detail) {
      const id = detail[1];
      if (!/^[1-9]\d*$/.test(id)) {
        fail(400, 'INVALID_ID', 'Некорректный идентификатор товара.');
        return;
      }
      const product = products.find((item) => String(item.id) === id);
      if (!product) {
        fail(404, 'PRODUCT_NOT_FOUND', 'Товар не найден.');
        return;
      }
      send(200, { product });
      return;
    }
    fail(404, 'ROUTE_NOT_FOUND', 'Маршрут не найден.');
  } catch (error) {
    console.error(error);
    fail(500, 'INTERNAL_ERROR', 'Не удалось обработать запрос.');
  }
}).listen(3000, '127.0.0.1');

Цены записаны в копейках. Для учебной лампы 159900 означает 1599 рублей. Клиент поделит число на 100 при отображении; хранить дробную сумму в этом примере не требуется.

Список товаров сначала проверяет параметр, затем обрезает массив. У детали иной порядок: проверка записи идентификатора, поиск товара, ответ. Если искать abc в массиве без проверки, API ответит «товар не найден» на неверный запрос и смешает два разных исхода.

Каждая ветка заканчивается return. Ответ уже отправлен. Продолжать поиск маршрута после этого не нужно. А res.end() завершает тело: без него клиент может продолжать ждать.

Соедините API с браузером

Сохраните страницу как index.html. Она загрузит список и создаст кнопку для каждой карточки. По нажатию отдельный запрос получит детали товара.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Каталог через API</title>
</head>
<body>
  <main>
    <h1>Учебный каталог</h1>
    <p id="status" role="status"></p>
    <ul id="products"></ul>
    <p id="detail" role="status"></p>
  </main>
  <script type="module">
    const status = document.querySelector('#status');
    const list = document.querySelector('#products');
    const detail = document.querySelector('#detail');

    async function getJSON(path) {
      const response = await fetch(path);
      if (!response.ok) throw new Error('HTTP ' + response.status);
      return response.json();
    }

    async function showProduct(id) {
      detail.textContent = 'Загружаем карточку…';
      try {
        const data = await getJSON('/api/products/' + id);
        const price = new Intl.NumberFormat('ru-RU', {
          style: 'currency', currency: data.product.currency,
        }).format(data.product.price / 100);
        detail.textContent = data.product.name + ': ' + price;
      } catch (error) {
        detail.textContent = 'Не удалось загрузить карточку.';
        console.error(error);
      }
    }

    status.textContent = 'Загружаем список…';
    try {
      const data = await getJSON('/api/products?limit=10');
      for (const product of data.items) {
        const item = document.createElement('li');
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = product.name;
        button.addEventListener('click', () => showProduct(product.id));
        item.append(button);
        list.append(item);
      }
      status.textContent = 'Товаров в каталоге: ' + data.total;
    } catch (error) {
      status.textContent = 'Не удалось загрузить список.';
      console.error(error);
    }
  </script>
</body>
</html>

Клиент обращается к относительным адресам. Страницу и API отдаёт один сервер, поэтому запросы остаются в одном источнике и настройка CORS здесь не нужна.

Название выводится через textContent. Строка из API становится текстом кнопки. Для запуска обработчика используем обычный button: он доступен с клавиатуры без дополнительных обработчиков Enter.

Начальный клиент показывает общее сообщение об ошибке. Полноценные состояния загрузки и повторный запрос можно добавить следующим упражнением; базовая работа Fetch разобрана в статье о запросах в JavaScript.

Проверьте контракт независимо от страницы

Из папки упражнения запустите сервер:

node server.mjs

Откройте http://127.0.0.1:3000. Ожидаем две кнопки товаров, сообщение «Товаров в каталоге: 2» и цену после выбора карточки. Если порт занят, сначала остановите другой учебный сервер на этом порте или согласованно измените адрес в примере.

Теперь в другом терминале выполните запросы по одному:

curl -i 'http://127.0.0.1:3000/api/products?limit=1'
curl -i http://127.0.0.1:3000/api/products/1
curl -i http://127.0.0.1:3000/api/products/999
curl -i http://127.0.0.1:3000/api/products/abc
curl -i 'http://127.0.0.1:3000/api/products?limit=0'
curl -i -X POST http://127.0.0.1:3000/api/products

Опция -i показывает статус и заголовки вместе с JSON. Сверяйте результат с таблицей контракта, а затем меняйте входные данные. Добавьте пустой limit, значение 1.5 и запись 01: наш договор должен отвергнуть каждую.

При 405 проверьте заголовок Allow: GET, HEAD. Для HEAD сервер возвращает соответствующий статус и заголовки без тела; проверить это можно командой curl -I http://127.0.0.1:3000/api/products/1.

Сохраните границы учебного API

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

Параметр limit ограничивает размер текущего ответа. Для перехода к следующим товарам понадобятся дополнительные правила пагинации: например, курсор и порядок сортировки. Пока возвращается начало фиксированного массива.

Клиент тоже ограничен. Если нажать несколько карточек подряд, более старый запрос способен закончиться позже нового и перезаписать детали. Для развития примера отменяйте прежний запрос или проверяйте идентификатор текущего выбора перед выводом.

Самостоятельная задача: добавьте третий вымышленный товар и проверьте total при limit=1. После этого добавьте в клиент явное пустое состояние. Чтобы увидеть его, временно оставьте массив товаров пустым и перезапустите сервер. Пустой список должен оставаться успешным ответом 200.

Проверьте базу и программу разработки API

Прежде чем переносить пример в большой проект, убедитесь, что можете объяснить каждый ответ таблицы без чтения обработчика. Если уже знакомы JavaScript, модули и основы TypeScript, сверяйте подготовку с входными требованиями курса «Node.js. Профессиональная разработка REST API». Сохраните учебный контракт: он пригодится, когда к каталогу добавятся база данных и операции записи.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники