Тема
Спецификация: API (REST)
Универсальная спека — конвенции REST API для всех проектов студии. Дополняет controllers.md (раздел «API Controllers»). Источники решений: архивные исследования API CMS v2, Bitrix24 REST, Stripe, Directus и Shopify. Исходные исследования не входят в опубликованный курс; применяемые правила собраны на этой странице. Спецификация версионно-независима.
Конверт ответа
Единый формат на всех эндпоинтах проекта — без исключений:
json
// успех
{ "data": ..., "meta": { "next_cursor": "..." }, "links": { ... } }
// ошибка
{ "error": { "code": "validation_failed", "message": "...", "fields": { "email": ["..."] } } }Правила
- Трансформация только через API Resources — сырой Eloquent в JSON запрещён
- Ошибка — машинный
code+ человеческийmessage; stack trace наружу — никогда (APP_DEBUG=falseв production — инвариант) - Ошибки валидации — в
error.fields, по полю массив сообщений (маппинг из 422) - В ошибке также
type(класс:invalid_request/api_error/idempotency_error) иdoc_url— ссылка на документацию кода ошибки (образец Stripe) Request-Id(ULID) в заголовке каждого ответа + тот же id в контексте логов (Log::withContext) — жалоба интегратора находится в логах за секунды
Идемпотентность мутаций
Обязательна, если API принимает мутации от внешних интеграций (1С, CRM, маркетплейсы) — их ретраи без неё создают дубли. Семантика Stripe:
- Заголовок
Idempotency-Key(UUID) на POST; сервер кэширует статус + тело первого ответа на 24 ч; повтор с тем же ключом → тот же ответ + заголовокIdempotent-Replayed: true - Тот же ключ + другое тело → сравнение fingerprint → ошибка
idempotency_error(не тихий replay) - Конкурентный запрос с тем же ключом → 409
idempotency_key_in_use - Клиенту документировать: 4xx → новый ключ, 5xx/сеть → повтор с тем же
- Одновременные мутации одного объекта —
Cache::lock("{model}:{id}"), таймаут → 429lock_timeout
Пагинация
| Режим | Когда | Как |
|---|---|---|
cursorPaginate() | По умолчанию; любые обходы, интеграции, мобильные | meta.next_cursor, стабильный порядок по индексированному полю |
paginate() (offset) | Только UI со страницами «1 2 3 …» | Осознанное исключение |
Правила
meta.total— опционален: только по явному флагу запроса (?with_count=1).COUNT(*)по фильтру на большой таблице — главный убийца list-эндпоинтов (замер Bitrix: 2.4 млн строк, 49.9 с → 0.097 с без count)- Полный обход датасета — только cursor, никогда offset (сдвиг страниц при параллельной записи = пропуски и дубли)
- Размер страницы — параметр с верхней границей (default 25, max 100), не хардкод
meta.has_more(bool) — явно в каждом списочном ответе- На ресурсах для интеграций — фильтр
?updated_since=+ cursor: штатный reconciliation-канал (вебхуки не гарантируют доставку — сверка обязана быть дешёвой)
Фильтрация и выборка полей
spatie/laravel-query-builder: фильтры/сортировки/includes — только whitelist; неизвестное поле → 422, не молчаливое игнорирование- Sparse fieldsets (
?fields=) — для тяжёлых ресурсов - Фильтры по JSONB-полям — только по проиндексированным ключам (functional/GIN index)
- Includes — лимит глубины ≤ 4 (Stripe expand); wildcard-разворот связей без потолка — источник 45-секундных запросов (боль Directus #7783)
- Связи по умолчанию — ID, объект — по явному include; чувствительные/дорогие поля — только по явному
?fields=, по умолчанию скрыты
Аутентификация и scope токенов
- Sanctum: токены для внешних клиентов, сессия — для SPA/островов того же домена
- Токен передаётся только в заголовке
Authorization: Bearer— не в query (URL оседает в логах, реферерах, истории) - Token abilities обязательны: токен выдаётся со scope-списком (
{область}:{read|write}, напр.catalog:read,leads:write); итоговое право = ability ∩ Policy пользователя. Токен интеграции ≠ полный доступ владельца - Режим в префиксе токена:
{app}_live_…/{app}_test_…+ флагlivemodeв событиях — стейджинг-интеграция физически не мутирует прод (образец Stripe) - Эндпоинт интроспекции прав
GET /api/v1/permissions/me— фронт строит формы по эффективным правам токена, не хардкодя роли (образец Directus) - Секрет/токен в коде или конфиге репозитория — запрещено (
.envonly)
Rate limiting
- Throttle-ключ: токен для аутентифицированных запросов, IP — только для гостей (интеграции за одним NAT не должны делить лимит)
- Стандартные заголовки
RateLimit-Limit/RateLimit-Remaining/RateLimit-Resetв каждом ответе API - Ответ при превышении — 429 с конвертом ошибки (
code: rate_limit_exceeded) - Batch-эндпоинт (если есть) = 1 хит лимитера на пакет
Версионирование
- Префикс URL
/api/v1; аддитивные изменения — без bump версии - Ломающее изменение →
/api/v2, старая версия живёт параллельно с заголовкамиDeprecationиSunsetминимум один цикл - Payload событий/вебхуков — тоже контракт: только аддитивные изменения внутри мажора
Вебхуки (исходящие)
- Подпись HMAC-SHA256 тела + timestamp (толеранс 5 минут, timing-safe сравнение); секрет — per-подписчик; ротация секрета с перекрытием — несколько валидных подписей, старый секрет живёт ≤ 24 ч
- Заголовки доставки:
Webhook-Id(дедупликация у получателя) +Event-Id(корреляция ретраев одного события) - Ретраи с экспоненциальным backoff + dead-letter + журнал доставки; автоотключение подписки после N провалов подряд + уведомление владельцу (мёртвые подписчики не должны крутиться в ретраях вечно)
- В update-событиях —
previous_attributes(diff изменённых полей «до»): подписчик синхронизируется без хранения прошлого состояния - Thin payload по умолчанию для внешних подписчиков:
{id, type, event, origin}, данные подписчик дозапрашивает по API (payload не устаревает, лишнее не утекает). Full payload — осознанный выбор подписки - Анти-эхо: поле
originв событии = источник изменения; подписчику не доставляются события с его собственнымorigin(иначе двусторонняя синхронизация сайт↔CRM зацикливается) - Идемпотентность по event-id на стороне подписчика — документировать в API-доках
Документация
- OpenAPI генерируется из кода (Scramble / L5-Swagger), валидируется в CI
- Если сущности/поля создаются в рантайме (типы контента, кастомные поля) — статической спеки мало: нужен runtime-эндпоинт схемы (
GET /api/v1/schema), мёржащий статику с фактическими схемами - Страница метода в доках: scope и «кто может» → параметры → пример запроса/ответа → таблица ошибок (шаблон Bitrix b24-rest-docs)
Массовые обмены
REST-пагинация не для выгрузки/загрузки сотен тысяч записей. Паттерн — асинхронный bulk-канал (образец Shopify Bulk Operations):
- Экспорт:
POST /bulk/exports {resource, filter}→ 202 + id → воркер пишет JSONL → вебхук о готовности с подписанным URL (TTL ~7 дней);partial_data_urlпри сбое; прогрессobject_count; иерархии — плоскими строками с__parentId - Импорт: загрузка JSONL (строка = запись) → асинхронная обработка → JSONL-отчёт, строка N = результат строки N, ошибки построчные
- Лимит: 1 активная операция на клиента; выполнение вне общего rate-limit
Anti-patterns
| Паттерн | Проблема | Решение |
|---|---|---|
paginate() для интеграционного обхода | Пропуски/дубли при параллельной записи, count на каждой странице | cursorPaginate() |
| Мутации от интеграций без Idempotency-Key | Ретрай 1С/CRM создаёт дубли заказов | Idempotency-middleware |
| Один тип токена на всё | Стейджинг-интеграция мутирует прод | Префикс _test_/_live_ |
| Include/expand без лимита глубины | 45-секундные запросы, обход прав | Whitelist + глубина ≤ 4 |
| Ревизии/журналы без ретеншна и индексов | Таблица на миллионы строк кладёт API | Ретеншн + джоба очистки с 1-го дня |
| Токен в query-параметре | Утечка через логи/рефереры | Заголовок Authorization |
| Токен без abilities | Интеграция получает все права владельца | Scope-список при выдаче |
| Полный payload вебхука наружу | Утечка данных, payload устаревает, отдельный semver | Thin payload + дозапрос |
| Rate limit по IP для всех | Интеграции за NAT делят лимит | Ключ = токен |
$model->toJson() в ответе | Утекают скрытые поля, контракт = структура БД | API Resource |
| Разные форматы ошибок по эндпоинтам | Клиент парсит зоопарк | Единый конверт error |