Проектирование API: как сервисы договариваются друг с другом и не переписывают всё через год

В прошлой статье мы решили, из каких кусков состоит система: Монолит против микросервисов описал, когда всё держать в одном процессе, а когда разносить по отдельным службам. Но разделение само по себе ничего не даёт. Три независимых сервиса бесполезны, пока не научатся передавать друг другу данные — быстро, однозначно и так, чтобы изменение в одном не ломало остальные два.

Эта статья — про способ, которым части вашей системы разговаривают между собой. Про API (application programming interface — публичный договор: какие вопросы можно задавать, в какой форме и какой ответ вы получите). Плохо спроектированное API — это причина, по которой команды возвращаются к монолиту: вроде бы разделили, а каждое изменение приходится согласовывать с четырьмя соседями и откатывать релиз.

Хорошая новость: 90% проблем решаются на бумаге до первой строчки кода. Плохая: их не решить потом — API, которое отдали наружу, нельзя «починить», можно только выпускать рядом вторую версию.

Разберём по порядку: соглашения REST, коды ответов, дизайн URI (адресной строки запроса), выбор между gRPC и GraphQL, письменный контракт в OpenAPI, версионирование и практика в PHP — WordPress REST API и Laravel Sanctum.


Часть I. REST-соглашения: метод, адрес, состояние

1. Что обещает REST и что он не обещает

REST (representational state transfer — «передача представления состояния») — архитектурный стиль, а не библиотека и не протокол. Он не говорит, как писать код; он говорит, как не говорить друг с другом на разных языках. Главная идея: клиент и сервер обмениваются представлением ресурса, а не командами.

Представьте ресурс как предмет: user, order, product, post. Представление — это описание предмета в момент запроса, обычно в JSON (текстовый формат обмена данными, похож на массив в PHP) или в XML. Клиент не просит «выполни подсчёт скидки» — он просит «дай мне представление заказа», а скидка уже лежит внутри.

Второе важное обещание REST: отсутствие состояния (statelessness). Каждый запрос самодостаточен: сервер не помнит предыдущий запрос этого клиента и не обязан помнить. Всё, что нужно, — в адресе, в заголовках и в теле самого запроса. Почему это важно: любой из десяти ваших серверов может обработать любой запрос, поэтому балансировщик нагрузки может направлять трафик куда угодно. Если же состояние живёт в памяти процесса («он на третьем шаге, я должен продолжить с того же сервера»), вы упираетесь в «липкие сессии» и теряете свободу масштабирования.

2. Четыре метода плюс два полезных

Базовый набор небольшой, и в нём почти всё объясняется семантикой (смыслом), а не спецификацией:

МетодСмыслПовторять безопасно?Тело запроса
GETПолучить представление ресурсаДа — метод безопасный, он ничего не меняетНет
POSTСоздать новый ресурс / запустить обработкуНет — два повтора создадут два заказаДа
PUTЗаменить представление целикомДа — идемпотентныйДа
PATCHИзменить часть представленияЗависит от формата; обычно аккуратнее с PUTДа
DELETEУдалить ресурсДа — после первого ответа остаток тот жеНет
HEADПолучить только заголовки, без телаДа — полезно для проверки «есть ли обновление»Нет

Идемпотентность (повторяемость) — простое понятие: сколько раз ни выполните запрос, результат для сервера одинаков. DELETE /orders/42 три раза подряд удалит заказ один раз и два раза ответит, что его нет. А POST /orders трижды создаст три заказа — и это самый частый источник дублей в платёжных системах: пользователь дважды нажал кнопку, клиент дважды отправил запрос, сеть трижды не ответила, и в итоге списание прошло дважды. Если ваш клиент не может гарантировать один повтор — передавайте ключ идемпотентности (идентификатор операции, который клиент придумывает сам и шлёт в заголовке; сервер по нему видит «это уже обработано»).

Ещё один метод, о котором забывают, — OPTIONS. Он нужен для браузеров: перед «междоменным» запросом браузер шлёт OPTIONS и спрашивает, разрешён ли такой обмен. Это CORS (cross-origin resource sharing — правило, по которому одному сайту можно обращаться к чужому серверу). Если ваш API открывает доступ из браузера, ответ на OPTIONS обязателен, иначе запрос просто не уйдёт.

3. Как это выглядит в терминале

Команды ниже — самый быстрый способ проверить своё API без клиента и без браузера:

# Читаем список: метод безопасный, ответ можно кешить
curl -i https://api.example.test/v1/products?limit=20

# Создаём ресурс: сервер должен ответить 201 и указать адрес нового объекта
curl -i -X POST https://api.example.test/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"title":"Кофемолка","price":4990}'

# Заменяем представление целиком (идемпотентно)
curl -i -X PUT https://api.example.test/v1/products/42 \
  -H 'Content-Type: application/json' \
  -d '{"title":"Кофемолка Pro","price":6990,"stock":7}'

# Меняем одно поле — для этого есть PATCH, а не «маленький POST»
curl -i -X PATCH https://api.example.test/v1/products/42 \
  -H 'Content-Type: application/json' \
  -d '{"price":5990}'

# Удаляем: ожидаем 204 и пустое тело
curl -i -X DELETE https://api.example.test/v1/products/42

Флаг -i печатает заголовки ответа — без них вы не увидите ни код состояния, ни заголовок Location, ни кеш-инструкции.

4. Как методы связаны с кешем

GET можно кешить (сохранять ответ, чтобы не делать запрос заново) — браузер, CDN, ваш собственный Redis. POST, PUT, PATCH — нельзя по определению. Поэтому API, где всё идёт через POST, автоматически лишается бесплатного ускорения: каждый просмотр карточки товара упирается в базу данных. Про то, как устроен кеш и когда его чистить, — отдельная статья серии Кеширование; здесь зафиксируем причинно-следственную связь: правильные методы — это не пуризм, а ваш пропуск в кеш.

Если вы читали статью прошлой серии Fetch API и REST, то помните клиентскую часть: fetch(url, { method: 'POST', body: JSON.stringify(...) }). Там разбирали, как запрос рождается в браузере и попадает в интерфейс. Здесь мы строим вторую половину — серверный договор.


Часть II. Коды ответов: клиент читает сначала их

5. Успех тоже бывает разный

HTTP-код состояния — первое, что видит клиент. Он короткое и однозначное «что случилось». Разберём набор, которого хватает на 95% случаев.

Успех:

  • 200 OK — всё хорошо, тело ответа содержит запрошенное представление. Универсальный ответ для GET, PUT, PATCH.
  • 201 Created — ресурс создан. Обязателен заголовок Location: адрес нового объекта, чтобы клиент мог сразу его прочитать. Отвечать 200 на создание — мелкая, но неприятная экономия: клиент не узнает адрес без второго запроса.
  • 204 No Content — команда выполнена, тела нет. Идеален для DELETE и для «принято, но говорить не о чем». Важно: 204 обязан иметь пустое тело, ноль байт. Если вы вернёте 204 и внутрь положите текст успешной операции, часть клиентов сломается.

Ошибка клиента (4xx — проблема в запросе, повтор его не исправит):

  • 400 Bad Request — запрос нельзя разобрать: битый JSON, неизвестное поле, неверный тип. Пример: прислали "price":"4990руб" вместо числа.
  • 401 Unauthorized — клиент не представился. Дословный перевод «не авторизован» вводит в заблуждение: смысл именно «мы не знаем, кто вы». Ответ должен содержать заголовок WWW-Authenticate с подсказкой, как представиться.
  • 403 Forbidden — мы знаем, кто вы, но вам нельзя. Разница с 401 принципиальна: 401 — «нет токена (ключа доступа)», 403 — «токен есть, прав не хватает». Путать их — значит заставлять клиента заново входить в аккаунт, когда вход уже нормальный.
  • 404 Not Found — ресурса нет. Стандартная уловка: если пользователь без прав просит чужой заказ, возвращать 404, а не 403, чтобы не подтверждать существование объекта.
  • 409 Conflict — запрос противоречит текущему состоянию: двойное создание, запись поверх изменённой версии, повторный платеж. Нужен при конкурентной записи и при конфликте уникальных полей.
  • 422 Unprocessable Content — формат правильный, содержимое невозможно обработать: email без @, цена отрицательная, дата в прошлом. Отличие от 400: разбираем-то мы всё смогли, не проходит проверка по правилам. Для форм валидации — это ваш основной код.
  • 429 Too Many Requests — слишком много обращений, в заголовке Retry-After надо написать, сколько секунд ждать. Про ограничение частоты обращений — статья Защита API: ограничение частоты, проверка входных данных, CORS, ключи.

Ошибка сервера (5xx — проблема у вас, и клиент ни при чём):

  • 500 Internal Server Error — внутри произошло то, что мы не предсказали. Это не «универсальный ответ об ошибке», это ваше уведомление в мониторинг. Каждый 500 должен быть разобран: если вы осознали причину, отправьте клиенту 4xx или новый 5xx-код, а не оставляйте 500.
  • 503 Service Unavailable — сервис временно не готов: база перегружена, идёт обновление, очередь переполнена. С Retry-After это честный и управляемый ответ, который клиенты и балансировщики обрабатывают правильно.

6. Единообразное тело ошибки

Код состояния — это «что в целом случилось». Тело — «что именно не так и как исправить». Требование простое: один формат на весь API, без самодельных вариантов на каждый контроллер.

Пример конфига JSON — стандартное тело ответа с ошибкой:

JSON
{
  "error": {
    "code": "validation_failed",
    "message": "Не удалось сохранить товар.",
    "details": [
      {
        "field": "price",
        "issue": "must_be_positive",
        "message": "Цена должна быть больше нуля.",
        "current": -100
      },
      {
        "field": "title",
        "issue": "too_long",
        "message": "Не больше 120 символов.",
        "current": 214,
        "limit": 120
      }
    ],
    "request_id": "b7f3d1e4-2a11-4c9f-8d5e-6c2f9b0a4d31"
  }
}

Что здесь важно:

  • code — машинная метка ошибки. Клиент принимает решение по ней, потому что текст можно перефразировать, а код ломать нельзя: он попадает в интерфейс и в тесты.
  • message — человекочитаемо и без внутренних подробностей. Никаких «в таблице products нарушено ограничение NOT NULL».
  • details[] — указание на конкретное поле. Пользователь должен увидеть красную строку рядом с полем, а не вверху экрана.
  • request_id — идентификатор обращения. Пользователь его копирует, вы им находите запись в логах. Без него разбор жалоб превращается в гадание; подробнее в статье Логирование и трассировка.

И главное правило, из-за которого его нарушают: текст ошибки не должен раскрывать внутренности. «Неверный пароль администратора» и «пользователь с таким адресом не найден» — это подарок тому, кто подбирает аккаунты. Формулируйте одинаково: «Неверные данные для входа».


Часть III. Дизайн URI: адрес как часть договора

7. Существительные во множественном числе, глаголы — в методе

URI (uniform resource identifier — адрес ресурса) — самая заметная часть API. Через полгода его читает новый программист, поэтому адрес должен быть предсказуемым.

Набор правил, который работает почти всегда:

  1. Имя ресурса — существительное во множественном числе. /products, /orders, /users. Не /product, не /getProducts, не /doOrder. Единственное число используйте только дляsingleton-ресурса, которого в системе ровно один: /me, /settings, /cart.
  2. Глаголов в адресе нет. Они уже есть в методе: создать — POST, заменить — PUT, удалить — DELETE. Надпись /delete-order?id=42 означает, что вы используете сеть как удалённый вызов функции и теряете всю пользу REST.
  3. Прикладная операция — отдельным вложенным ресурсом, а не глаголом. Отмена заказа — это POST /orders/42/cancellations (создаётся объект «отмена»), а не POST /cancelOrder. Так операция становится тем, что можно закешировать-не-надо, показать в истории, отменить и описать в спецификации.
  4. Вложенность — до двух уровней. /orders/42/items/7 — нормально. /users/9/orders/42/items/7/comments/115 — признак того, что вы строите дерево вместо плоских ресурсов. Если идентификатор уникален глобально, смело делайте /comments/115 и связывайте через поля.
  5. Фильтры, сортировка, выдача постранично — в параметрах запроса, не в адресе. /products?category=tools&min_price=1000&sort=-price&limit=20&offset=40. Адрес /products/tools/cheap/sorted — это отдельная конечная точка на каждую комбинацию, и их станет сорок.
  6. Строчные буквы и дефисы. /shopping-carts, а не /ShoppingCarts и не /shopping_carts. Регистр в адресе различается серверами по-разному, и /Products через месяц окажется другим ресурсом.
  7. Согласованностьslashes. Либо везде без завершающего /, либо везде с ним. Смешивание даёт двойные записи в кеше и путаницу в правилах маршрутизации.
  8. Версия в адресе или в заголовке — но обязательна.

8. Кол-во, выдача постранично и метки

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

  • Смещение и размер (offset + limit) — просто, но плывёт, если между запросами строки добавились или удалились: пользователь увидит один товар дважды и один пропустит.
  • Метка продолжения (cursor) — сервер отдаёт «здесь оборвали, дальше вот от этой метки»: ?limit=20&cursor=eyJpZCI6MTIzfQ. Стабильно при живой записи, чуть сложнее в объяснении.

Вместе с первой страницей отдавайте заголовки X-Total-Count (или поле total) и Link: <...?cursor=...>; rel="next" — тогда клиенту не нужно угадывать, есть ли продолжение.

Для тяжёлых выборок (выгрузка, отчёт) используйте пакетную выдачу: создайте ресурс-задачу и верните 202 Accepted (принято, работаем асинхронно) с адресом статуса: GET /exports/77 → «в очереди», затем Location на готовый файл. Это ровно тот случай, где прямой вызов нужно менять на фоновую обработку — переход к статье Очереди сообщений.


Часть IV. gRPC против GraphQL: не «лучше/хуже», а «для кого»

9. protobuf и быстрый внутренний трафик

gRPC — способ общения между сервисами, где обмен идёт не текстом, а компактным бинарным (двоичным) сообщением. Форма описывается в protobuf (Protocol Buffers — «описания сообщений в компактном двоичном формате»): вы один раз пишете описание структуры данных, а генератор делает из неё код на PHP, Go, Python и JavaScript.

Почему его любят внутри системы:

  • Меньше байтов. Та же карточка товара в JSON занимает в 3–5 раз больше места, чем в protobuf, потому что там нет повторения имён полей и кавычек.
  • Быстрее разбор. Двоичный формат не нужно превращать из текста в объект — и при миллионах обращений экономия становится заметной.
  • Строгая форма. Сервис не может прислать «почти то же, но с другим названием поля»: обе стороны сгенерированы из одного описания. Это лучший из известных способов исключить класс ошибок «пришли строку вместо числа».
  • Дедлайны и отмена. Клиент передаёт «ждать не больше 300 миллисекунд», и сервер сам останавливает работу, если время вышло. Без этого один медленный запрос по цепочке съедает всех.
  • Потоковая передача. Одно соединение — много сообщений в обе стороны: удобно для выгрузок и для живых обновлений (ср. со статьёй Потоки: SSE и WebSocket).

Цена: браузер не умеет говорить на protobuf напрямую, адресRequest-a не читается глазами при отладке, а инструмент «открыть и посмотреть» приходится собирать специально (обычно через прокси, который переводит JSON в gRPC). Внутренний трафик — да; публичный API для чужих разработчиков — нет.

10. GraphQL и право клиента выбрать поля

GraphQL — подход, где у сервера одна точка входа (например POST /graphql), а клиент текстом описывает, какие поля и какой формы ответа он хочет. Не «дай карточку товара», а «дай товар 42: название, цена, и пять первых отзывов — автор и оценка».

Пример на JavaScript — запрос клиента, который сам решает, какие поля ему нужны:

// client/product-card.js — забираем ровно то, что рисуем
const query = `
  query ProductCard($id: ID!) {
    product(id: $id) {
      title
      price
      stock
      reviews(limit: 5) {
        author
        rating
      }
    }
  }
`;

const response = await fetch('https://api.example.test/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query, variables: { id: '42' } })
});

const { data } = await response.json();

Плюсы:

  • Нет лишних данных (over-fetching — «пришло больше, чем нужно») и нет проблемы N+1 со стороны клиента: один запрос вместо шести.
  • Один сервер обслуживает и мобильное приложение с маленьким экраном, и админку с широким — каждый берёт своё.
  • Типы описаны на месте (схема GraphQL), редактор подсказывает поля, а запрос с несуществующим полем отклоняется до исполнения.

Минусы, о которых честно забывают:

  • Кеш ломается. HTTP умеет кешить GET /products/42; произвольный текст запроса в теле POST — нет. Приходится кешировать на сервере или переходить на хеш запроса.
  • Опасность произвольного запроса. Клиент может попросить вложенность в десять уровней и получить взрыв нагрузки. Обязательны лимиты глубины, лимиты сложности и ограничение частоты обращений.
  • Резолверы (функции-обработчики отдельных полей) легко уводят вас в проблему N+1 уже на сервере. Лечится батчингом и кешем — но это нужно делать сознательно, подробнее в статье Оптимизация баз данных.

11. Как выбрать

Что нужноЧто брать
Много обращений между своими сервисами, критична задержкаgRPC
Строгий контракт, генерация клиента, потоковая передачаgRPC
Публичный API для сторонних разработчиковREST + OpenAPI
Клиентов много и у каждого свой набор полей (мобильное, админка, сайт)GraphQL
Нужен бесплатный кеш браузера и CDNREST (метод GET)
Команда небольшая, доменная модель простаяREST — не усложняйте

И одно честное замечание: выбор здесь — не на всю жизнь. Типичная схема «REST снаружи, gRPC внутри» живёт годами и работает хорошо. А GraphQL снаружи имеет смысл, только когда у вас действительно несколько разных клиентов с разными требованиями к полям; для одного фронтенда это лишняя сложность.


Часть V. OpenAPI: контракт между сервисами в письменном виде

12. Что это и зачем

OpenAPI — текстовое описание вашего API в машиночитаемом виде: какие есть адреса, какие методы, какие поля в теле, какие коды ответов, как авторизоваться. Swagger — семейство инструментов, которые это описание читают: рисуют документацию, запускают «попробовать прямо в браузере», генерируют клиентский код. Формат записи — YAML или JSON; популярен именно YAML (человекочитаемый формат списков и пар «ключ: значение»).

Зачем это нужно, кроме документации:

  1. Контракт. Изменение в API перестаёт быть частным делом одного программиста: правка спецификации видна в дифе (сравнении версий файла) на проверке кода.
  2. Генерация клиента. Из одной спецификации получают клиент на PHP, Go, Python, JavaScript. Руки не делают опечатки в именах полей.
  3. Проверка форм. Валидатор (например, через middleware) сверяет фактический ответ с описанием и падает на этапе теста, а не на проде.
  4. Заглушка для параллельной работы. Фронтенд начинает работу по описанию за два дня до того, как сервер готов: фикстуры и «моки» (поддельные ответы) строятся из спецификации автоматически.
  5. База для тестов. Можно сгенерировать проверки на все описанные коды ответов и не забыть 409 или 422.

Пример конфига JSON — фрагмент спецификации со списком конечных точек каталога:

JSON
{
  "openapi": "3.1.0",
  "info": {
    "title": "Catalog API",
    "version": "1.2.0"
  },
  "servers": [
    { "url": "https://api.example.test/v1" }
  ],
  "paths": {
    "/products": {
      "get": {
        "summary": "Список товаров с фильтром и выдачей постранично",
        "operationId": "listProducts",
        "parameters": [
          { "name": "category", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "maximum": 100, "default": 20 } },
          { "name": "cursor", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Очередная страница списка",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ProductList" }
              }
            }
          },
          "429": { "description": "Слишком много обращений, см. Retry-After" }
        }
      },
      "post": {
        "summary": "Создать товар",
        "operationId": "createProduct",
        "security": [ { "bearerAuth": [] } ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/ProductInput" } }
          }
        },
        "responses": {
          "201": {
            "description": "Создано",
            "headers": {
              "Location": { "description": "Адрес нового товара" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Product" } }
            }
          },
          "401": { "description": "Нет ключа доступа" },
          "422": { "description": "Не прошло проверку по правилам" }
        }
      }
    },
    "/products/{id}": {
      "get": {
        "summary": "Карточка товара",
        "operationId": "getProduct",
        "responses": {
          "200": { "description": "Представление товара" },
          "404": { "description": "Товара нет" }
        }
      },
      "patch": {
        "summary": "Изменить отдельные поля",
        "operationId": "updateProduct",
        "responses": {
          "200": { "description": "Изменено" },
          "404": { "description": "Товара нет" },
          "409": { "description": "Версия устарела, перечитайте" },
          "422": { "description": "Не прошло проверку" }
        }
      },
      "delete": {
        "summary": "Удалить товар",
        "operationId": "deleteProduct",
        "responses": {
          "204": { "description": "Удалено, тело пустое" },
          "403": { "description": "Недостаточно прав" },
          "404": { "description": "Товара нет" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer" }
    },
    "schemas": {
      "Product": {
        "type": "object",
        "required": ["id", "title", "price"],
        "properties": {
          "id": { "type": "integer", "readOnly": true },
          "title": { "type": "string", "maxLength": 120 },
          "price": { "type": "integer", "minimum": 0 },
          "stock": { "type": "integer", "minimum": 0 }
        }
      }
    }
  }
}

13. Как вести спецификацию в реальности

Рабочая практика, которая отличает живой файл от мёртвого:

  • Спецификация в том же репозитории, что и код, и меняется в том же коммите. Документация, которая лежит в отдельной вики, отстаёт за месяц.
  • Правило «сначала описание, потом реализация». Нет правки в спецификации — нет кода. Это снимает споры на проверке: видно, что именно изменилось в договоре.
  • Проверка в конвейере сборки. Запустите валидатор и проверку, что фактические ответы совпадают с описанием; иначе файл расходится с кодом и перестаёт кому-либо верить.
  • Раздел «изменения» (changelog) и пометка «устарело». Поле или конечная точка помечаются deprecated: true с указанием замены и датой отключения; удаление без предупреждения — прямой путь к чужому инциденту.
  • Примеры в описании. Один реальный запрос и один реальный ответ на каждый метод: по примерам пишут клиенты, а не по описанию типов.

Часть VI. Версионирование: /v1/ против заголовка

14. Почему версия обязательна

Любое публичное обращение, которое кто-то начал использовать, становится вашим обязательством. Через полгода вы захотите изменить формат цены с копеек на рубли или убрать лишнее поле — и выясните, что «мелкая правка» ломает чужой код. Версия — это способ менять договор, не ломая старую работу: старая версия живёт параллельно, пока клиенты переедут.

Внутренние сервисы — исключение. Когда и отправитель, и получатель — ваш код, деплоятся вместе, и потребителей ровно один, версии часто не нужны: достаточно обратной совместимости. Но если у API больше одного потребителя или потребители деплоятся отдельно — версию закладывайте сразу, второй раз это будет дороже.

15. Версия в адресе

Форма: https://api.example.test/v1/products/42, при необходимости v1.2.

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

Минусы: адрес перестаёт быть чистым идентификатором ресурса (строго говоря, /v1/products и /v2/products — два разных ресурса); при длинной цепочке вызовов версия просачивается во все внутренние адреса; «мелких» версий может накопиться шесть, и вы содержите шесть веток логики.

16. Версия в заголовке

Форма: Accept: application/vnd.company.api+json;version=2, либо собственный заголовок X-Api-Version: 2.

Плюсы: адрес остаётся чистым — ресурс один, версий много; согласуется с идеей HTTP о согласовании формата через Accept; удобно для библиотек, которые меняют заголовок в одном месте.

Минусы: версии не видно в обычном логе и в адресной строке, отладка требует инструмента; кеш и CDN должны различать запросы по заголовку (нужно явно указывать Vary: Accept, иначе версии начнут подменять друг друга — подробнее в статье CDN и edge-вычисления); браузером не проверить; «непонятный» для внешних потребителей, которые привыкли видеть версию в адресе.

17. Что делать с содержимым версии

Правила совместимости, которые спасают нервы:

  • Обратно совместимое изменение — новая версия не нужна: добавить необязательное поле, добавить конечную точку, добавить поддерживаемый формат.
  • Совместимое с оговорками — новая минорная версия и пометка «устарело»: переименование поля с временным дублированием старого, сужение диапазона значений.
  • Ломающее изменение — только новая основная версия: удаление поля, изменение типа, новый обязательный параметр, смена смысла кода ответа.
  • Никогда не меняйте смысл без изменения версии. Клиент, который считал price в копейках, не узнает о рублях: он просто купит товар в сто раз дороже.
  • Сроки отключения объявите заранее и предупредите в заголовке Deprecation или Sunset с датой; мониторинг (см. Метрики и мониторинг) покажите, сколько обращений ещё идёт на старую версию, — это единственный честный ответ на вопрос «можно ли уже отключать».

Для большинства проектов выбор такой: снаружи /v1/ в адресе, внутри сервисов — заголовок или вообще без версии. Это не компромисс от бедности: публичному интерфейсу нужна прозрачность, внутреннему — чистота адресов.


Часть VII. PHP-практика: каркас контроллера, WordPress и Laravel

18. REST-контроллер на голом PHP

Скелет, который годится как основа маленького API-сервиса на одном VPS. Никаких фреймворков — только понятные части: разбор адреса, проверка прав, чтение JSON, единый вывод ошибки.

Пример на PHP — каркас REST-контроллера:

PHP
<?php
// src/Controllers/ProductController.php — конечная точка /v1/products/{id}

declare(strict_types=1);

final class ProductController
{
    public function __construct(
        private readonly ProductRepository $repo,
        private readonly AccessGate $gate
    ) {}

    /**
     * GET /v1/products/42 — безопасный, идемпотентный, кешable.
     */
    public function show(int $id): array
    {
        $product = $this->repo->findById($id);

        if ($product === null) {
            // Ресурса нет — это 404, а не 500 с «пустым результатом».
            throw new ApiException('not_found', 'Товара нет.', 404);
        }

        // Кеш-инструкции: приватный ответ на 30 секунд.
        header('Cache-Control: private, max-age=30');
        header('ETag: "' . $product->etag() . '"');

        return [
            'status' => 200,
            'body'   => $product->toArray(),
        ];
    }

    /**
     * PATCH /v1/products/42 — изменяем отдельные поля.
     */
    public function update(int $id, Request $request): array
    {
        // 1. Сначала проверяем, кто это (401/403), а потом содержимое.
        $this->gate->requirePermission('products.write', $request);

        // 2. Разбираем тело: пустое или битое — это 400.
        $data = $request->jsonOrThrow();

        // 3. Проверяем правила: не прошло — 422 со списком полей.
        $errors = (new ProductValidator())->check($data);
        if ($errors !== []) {
            throw new ValidationException($errors);
        }

        // 4. Конкурентная запись: клиент прислал версию, по которой читал.
        $expected = $request->header('If-Match');
        if ($expected !== null && !$this->repo->matchesEtag($id, $expected)) {
            throw new ApiException('version_conflict', 'Данные уже изменены кем-то другим.', 409);
        }

        $product = $this->repo->patch($id, $data);

        return [
            'status' => 200,
            'body'   => $product->toArray(),
        ];
    }

    /**
     * DELETE /v1/products/42 — идемпотентно, ответ 204 без тела.
     */
    public function destroy(int $id, Request $request): array
    {
        $this->gate->requirePermission('products.delete', $request);

        // Повторный вызов для уже удалённого объекта — тоже 204, не 500.
        $this->repo->softDelete($id);

        return [
            'status' => 204,
            'body'   => null,
        ];
    }
}

Обратите внимание на порядок проверок внутри метода: сначала права, потом формат, потом правила, потом конкурентная запись. Если поменять местами «права» и «формат», вы дадите любому незалогиненному человеку бесплатно проверять корректность данных и загружать сервер разбором чужого мусора.

Обработчик, который превращает исключения в единый формат ответа:

Пример на PHP — единый вывод ошибки:

PHP
<?php
// src/Http/handleError.php — одна точка, один формат на весь API

function send_error(Throwable $e, string $requestId): never
{
    $payload = [
        'error' => [
            'code'       => $e instanceof ApiException ? $e->machineCode : 'internal',
            'message'    => $e instanceof ApiException ? $e->safeMessage : 'Сервис временно недоступен.',
            'request_id' => $requestId,
        ],
    ];

    if ($e instanceof ValidationException) {
        $payload['error']['details'] = $e->details;
    }

    // Из текстов ошибок наружу не уходит ни SQL, ни имя таблицы, ни стек вызовов.
    $status = $e instanceof ApiException ? $e->statusCode : 500;

    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    header('X-Request-Id: ' . $requestId);

    if ($status === 204) {
        return; // тело у 204 обязано быть пустым
    }

    echo json_encode($payload, JSON_UNESCAPED_UNICODE);
    exit;
}

Отдельная строка, на которой стоит задержать внимание: при 500 наружу уходит общий текст «сервис временно недоступен», а настоящая причина — только в логи и в метрики. Полный 500 с текстом исключения — самый быстрый способ рассказать атакующему версию вашей базы данных и имя таблицы.

19. WordPress REST API как референс соглашений

WordPress давно имеет собственный REST-интерфейс, и он хорош именно как образец зрелого договора, а не как «модуль для плагинов». Что стоит скопировать к себе:

  • Пространство имён с версией: /wp-json/wp/v2/posts — версия живёт в «неймспейсе» (wp/v2), поэтому у плагинов есть собственные версии (/wp-json/my-shop/v1/orders) и они не конфликтуют.
  • Существительные во множественном числе: posts, pages, users, categories, media.
  • Параметры выборки в запросе: ?per_page=20&page=3&search=кофе&orderby=date&order=desc&status=publish. Плюс ответ в заголовках X-WP-Total и X-WP-TotalPages — клиент узнаёт размер выборки без лишнего обращения.
  • Отдельный запрос схемы: GET /wp-json/ и GET /wp-json/wp/v2/posts/schema — сервер сам описывает свои поля и типы, то есть контракт доступен по тому же адресу, что и данные.
  • Права — декларативно: при регистрации маршрута указывается permission_callback, и вызов метода отдельно проверяется сервером: «можно ли это сделать именно этому пользователю». Отсутствие колбэка прав — распространённая ошибка плагина, которая делает конечную точку публичной.

Пример на PHP — регистрация конечной точки каталога в WordPress:

PHP
<?php
// my-plugin/includes/rest.php

add_action('rest_api_init', function (): void {
    register_rest_route('my-shop/v1', '/products', [
        [
            'methods'             => 'GET',
            'permission_callback' => '__return_true', // чтение — публично
            'callback'            => function (WP_REST_Request $req): WP_REST_Response {
                $items = my_shop_products((array) $req->get_query_params());

                $response = new WP_REST_Response($items, 200);
                $response->header('X-Total-Count', (string) $items['total']);

                return $response;
            },
            'args' => [
                'per_page' => [
                    'validate_callback' => static fn ($v): bool => is_numeric($v) && $v > 0 && $v <= 100,
                    'default'           => 20,
                ],
            ],
        ],
        [
            'methods'             => 'POST',
            'permission_callback' => static fn (): bool => current_user_can('manage_options'),
            'callback'            => static function (WP_REST_Request $req): WP_REST_Response|int {
                $data = $req->get_json_params();

                if (!is_array($data)) {
                    return new WP_REST_Response(
                        ['error' => ['code' => 'invalid_body', 'message' => 'Ожидался JSON.']],
                        400
                    );
                }

                $id = my_shop_create_product($data);
                if ($id === 0) {
                    return new WP_REST_Response(
                        ['error' => ['code' => 'validation_failed', 'message' => 'Не прошло проверку.']],
                        422
                    );
                }

                $response = new WP_REST_Response(['id' => $id], 201);
                $response->header('Location', rest_url('my-shop/v1/products/' . $id));

                return $response;
            },
        ],
    ]);
});

Что здесь полезного для любого проекта: проверка прав отделена от логики, параметры описаны вместе с правилом проверки, коды ответов различают «нет прав», «битое тело» и «не прошло проверку», а созданному ресурсу возвращается адрес. Что вредного: встроенный формат ошибок WordPress (code, message, data) отличается от нашего из Части II — поэтому, если вы делаете API поверх WordPress, приведите формат к одному виду и не смешивайте.

20. Laravel Sanctum как образец простой авторизации для API

Sanctum — пакет Laravel для выдачи ключей доступа (токенов) и для аутентификации одностраничных приложений через куки. Полезен он не как набор функций, а как набор решений:

  • Два типа доступа в одном интерфейсе: «долгоживущий» токен для скриптов и интеграций (Authorization: Bearer ...) и сеансовая (cookie-based) аутентификация для своего фронтенда на том же домене. Вам не нужен полноценный OAuth (сложная схема делегирования доступа) для собственного клиента.
  • Ограничение области прав у каждого ключа (scopes): токен мобильного приложения умеет read, токен интеграции — read:products write:products. Просроченный или лишённый прав ключ даёт 403, а не 401 — ровно как мы договорились выше.
  • Хеширование ключа вместо хранения. Sanctum хранит не сам токен, а его хеш: утечка базы не даёт рабочим ключей. Об этом подробно — в статье Безопасная аутентификация: хеширование, JWT, OAuth2, 2FA.
  • Отзыв по одному ключу. Каждый выданный доступ — запись, которую можно выключить отдельно, а не «сменить секрет всем».
  • Маршруты описаны в одном месте, а middleware auth:sanctum проверяет доступ до контроллера. Так правило «сначала права, потом содержимое» обеспечивается архитектурно, а не внимательностью программиста.

Практический вывод для PHP-проекта: если у вас Laravel, не изобретайте свою схему ключей — Sanctum закрывает 90% случаев. Если у вас WordPress или чистый PHP, возьмите из Sanctum три вещи: набор прав у каждого ключа, хеш вместо хранения и возможность отозвать один ключ.


Инструмент под задачу

Готовую OpenAPI-спецификацию с корректными paths/securitySchemes под описанный выше договор проще сгенерировать по примеру, чем вычитать руками. Попробуйте инструмент прямо здесь, на своих данных:

📘 Генератор API-документации
Гость
Токены: 0 / 50 000
Гостевой лимит сбрасывается раз в месяц. Зарегистрируйтесь — лимит выше.
0 / 15 000 символов
● ● ● Генерация документации…

Результат
Вставьте код и нажмите «Сгенерировать документацию». OpenAPI, Markdown или HTML-описание появится здесь.

Лимит токенов исчерпан

Ваш бесплатный лимит закончился.

Чек-лист проектирования API

  • Методы используются по смыслу? Чтение — GET, создание — POST, замена — PUT, частичное изменение — PATCH, удаление — DELETE. Если у вас везде POST — причина «нормально работает» не оправдывает потерю кеша и проверок.
  • Клиент определяет поведение по коду состояния, а не по тексту и не по полю success: true внутри 200?
  • Формат ошибки один на весь API и содержит машинный code, безопасный message, список details и request_id?
  • В адресе нет глаголов, ресурсы — существительные во множественном числе, вложенность не глубже двух уровней?
  • Списки делятся на страницы, а общее число выдаётся (X-Total-Count или поле total)?
  • Все POST-создания идемпотентны при повторе (уникальный клиентский идентификатор операции)?
  • Спецификация OpenAPI лежит в репозитории, правится в том же коммите и проверяется в конвейере сборки?
  • Есть версия, у старого контракта объявлена дата отключения, а в метриках видно, сколько обращений идёт на устаревшую версию?
  • Проверка прав выполняется раньше разбора тела, и 401 отличается от 403?
  • Для тяжёлых операций — 202 Accepted и адрес статуса, а не удержание соединения на три минуты?
  • 500 разбирается как инцидент, а не как штатный ответ, и наружу не отдаёт стек вызовов?
  • Выбор транспорта осознан: REST снаружи, gRPC внутри, GraphQL только если потребителей правда много и у них разные наборы полей?

Итог. Договор, который можно прочитать

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

Материал написан простым языком без излишней терминологии; любой англоязычный термин объясняется при первом употреблении. Примеры опираются на PHP, WordPress и Laravel как на знакомый автору стек и на клиентскую часть из предыдущей серии «Веб-разработка без фреймворков». Конкретные номера версий инструментов опускаются сознательно — проверяйте поддержку по официальной документации.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.