В прошлой статье мы решили, из каких кусков состоит система: Монолит против микросервисов описал, когда всё держать в одном процессе, а когда разносить по отдельным службам. Но разделение само по себе ничего не даёт. Три независимых сервиса бесполезны, пока не научатся передавать друг другу данные — быстро, однозначно и так, чтобы изменение в одном не ломало остальные два.
Эта статья — про способ, которым части вашей системы разговаривают между собой. Про 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 — стандартное тело ответа с ошибкой:
{
"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. Через полгода его читает новый программист, поэтому адрес должен быть предсказуемым.
Набор правил, который работает почти всегда:
- Имя ресурса — существительное во множественном числе.
/products,/orders,/users. Не/product, не/getProducts, не/doOrder. Единственное число используйте только дляsingleton-ресурса, которого в системе ровно один:/me,/settings,/cart. - Глаголов в адресе нет. Они уже есть в методе: создать —
POST, заменить —PUT, удалить —DELETE. Надпись/delete-order?id=42означает, что вы используете сеть как удалённый вызов функции и теряете всю пользу REST. - Прикладная операция — отдельным вложенным ресурсом, а не глаголом. Отмена заказа — это
POST /orders/42/cancellations(создаётся объект «отмена»), а неPOST /cancelOrder. Так операция становится тем, что можно закешировать-не-надо, показать в истории, отменить и описать в спецификации. - Вложенность — до двух уровней.
/orders/42/items/7— нормально./users/9/orders/42/items/7/comments/115— признак того, что вы строите дерево вместо плоских ресурсов. Если идентификатор уникален глобально, смело делайте/comments/115и связывайте через поля. - Фильтры, сортировка, выдача постранично — в параметрах запроса, не в адресе.
/products?category=tools&min_price=1000&sort=-price&limit=20&offset=40. Адрес/products/tools/cheap/sorted— это отдельная конечная точка на каждую комбинацию, и их станет сорок. - Строчные буквы и дефисы.
/shopping-carts, а не/ShoppingCartsи не/shopping_carts. Регистр в адресе различается серверами по-разному, и/Productsчерез месяц окажется другим ресурсом. - Согласованностьslashes. Либо везде без завершающего
/, либо везде с ним. Смешивание даёт двойные записи в кеше и путаницу в правилах маршрутизации. - Версия в адресе или в заголовке — но обязательна.
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 |
| Нужен бесплатный кеш браузера и CDN | REST (метод GET) |
| Команда небольшая, доменная модель простая | REST — не усложняйте |
И одно честное замечание: выбор здесь — не на всю жизнь. Типичная схема «REST снаружи, gRPC внутри» живёт годами и работает хорошо. А GraphQL снаружи имеет смысл, только когда у вас действительно несколько разных клиентов с разными требованиями к полям; для одного фронтенда это лишняя сложность.
Часть V. OpenAPI: контракт между сервисами в письменном виде
12. Что это и зачем
OpenAPI — текстовое описание вашего API в машиночитаемом виде: какие есть адреса, какие методы, какие поля в теле, какие коды ответов, как авторизоваться. Swagger — семейство инструментов, которые это описание читают: рисуют документацию, запускают «попробовать прямо в браузере», генерируют клиентский код. Формат записи — YAML или JSON; популярен именно YAML (человекочитаемый формат списков и пар «ключ: значение»).
Зачем это нужно, кроме документации:
- Контракт. Изменение в API перестаёт быть частным делом одного программиста: правка спецификации видна в дифе (сравнении версий файла) на проверке кода.
- Генерация клиента. Из одной спецификации получают клиент на PHP, Go, Python, JavaScript. Руки не делают опечатки в именах полей.
- Проверка форм. Валидатор (например, через middleware) сверяет фактический ответ с описанием и падает на этапе теста, а не на проде.
- Заглушка для параллельной работы. Фронтенд начинает работу по описанию за два дня до того, как сервер готов: фикстуры и «моки» (поддельные ответы) строятся из спецификации автоматически.
- База для тестов. Можно сгенерировать проверки на все описанные коды ответов и не забыть 409 или 422.
Пример конфига 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
// 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
// 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
// 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
- Методы используются по смыслу? Чтение —
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 как на знакомый автору стек и на клиентскую часть из предыдущей серии «Веб-разработка без фреймворков». Конкретные номера версий инструментов опускаются сознательно — проверяйте поддержку по официальной документации.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
