Прямой ответ
Хороший REST API предсказуем: ресурсы — существительные во множественном числе (/orders, /orders/42/items), действия — HTTP-методы (GET читает, POST создаёт, PUT/PATCH обновляет, DELETE удаляет), статусы — по семантике RFC (200/201/204, 400/401/403/404/409/422, 500), ошибки — единого формата с машиночитаемым кодом, списки — с пагинацией с первого дня. Эти конвенции не «стиль», а экономия: клиент угадывает поведение до чтения документации, а команда не изобретает getOrderList в каждой новой ручке. Контракт фиксируется OpenAPI-схемой (подход — в статье про контрактную разработку); реализация на FastAPI получает схему и валидацию почти бесплатно (механика).
Ресурсы и URL: грамматика, которую читают
Правила, которые снимают 90% споров: множественное число (/users, не /user); вложенность — для принадлежности не глубже двух уровней (/orders/42/items, но не /users/7/orders/42/items/3 — глубокая вложенность хрупка и длинна); фильтры — query-параметрами (/orders?status=paid&created_after=...), не частью пути (/getPaidOrders — глаголы в URL признак RPC-мышления); действия вне CRUD — как подресурсы-существительные или POST к ресурсу (POST /orders/42/cancellation); сортировка/поля — параметрами (sort=-created_at, fields=id,total). Кейс — snake_case или kebab-case последовательно; trailing slash — одна политика (и редирект, а не два разных URL).
Методы, идемпотентность и безопасность
GET — безопасен (не меняет состояние, кешируем); POST — создание/действие (не идемпотентен: повтор создаёт вторую сущность — отсюда idempotency-key для платёжных операций); PUT — полная замена (идемпотентен: повтор безвреден); PATCH — частичное обновление (семантика merge — задокументируйте, «частичный PUT» ломает клиентов); DELETE — идемпотентен по эффекту. Идемпотентность — не педантизм: клиенты ретраят сетевые ошибки автоматически (о ретраях на стороне клиента — статья про HTTP-клиенты), и неидемпотентный POST под ретраем — дубли заказов. Ответы: 200 с телом / 201 + Location для создания / 204 без тела; «200 с {error: ...}» — антипаттерн, ломающий обработку.
Коды статуса: словарь, а не украшение
Минимально достаточный набор: 400 — запрос синтаксически/семантически неправилен «вообще»; 401 — не аутентифицирован (кто ты? — механика токенов в статье про JWT/OAuth); 403 — аутентифицирован, но нельзя; 404 — ресурс не найден (и для «нет права знать о существовании» — осознанная политика); 409 — конфликт состояния (дубликат, неверная версия); 422 — валидация тела (FastAPI-дефолт); 429 — rate limit (+ Retry-After); 500 — необработанная ошибка сервера. Антипаттерны: «всё 200, ошибки в теле» (клиент не может обработать ветвление статусами), «400 на всё» (401/403/404 существуют не зря), 500 на пользовательскую ошибку. Правило: статус выбирается по причине, и один класс причин — один статус во всём API.
Ошибки, пагинация и эволюция
Единый формат ошибки — контракт не менее важный, чем успех: {error: {code: "ORDER_NOT_FOUND", message: "...", details: [...]}} — стабильный машиночитаемый code (по нему ветвится клиент), человекочитаемое message, детали валидации по полям. RFC 7807 (problem+json) — готовый стандарт формата. Пагинация — offset/limit для простоты, cursor-пагинация для больших/живых данных (OFFSET деградирует на глубоких страницах); метаданные (total/next_cursor) — в теле или заголовках последовательно. Эволюция: поля добавляются без изменения версии (клиенты обязаны игнорировать неизвестное — зафиксируйте в контракте), ломающие изменения — новая мажорная версия (/v2) с периодом сосуществования; заголовочное версионирование — на вкус команды, путь проще для отладки.
Производительность и эксплуатация контракта
Практические детали, отличающие зрелый API: частичные представления (fields= — не отдавать 40 полей списку, где нужны 3); batch-эндпоинты против N вызовов (клиентский N+1 — та же болезнь, что в ORM — разбор); rate limiting с честными заголовками (X-RateLimit-Remaining); таймауты и лимиты размера тела; идемпотентные GET для кешируемости (ETag/Cache-Control — самый дешёвый слой масштабирования); корреляционный request-id в ответах (разбор инцидентов «по номеру запроса» — спасение поддержки). И документация — не отдельный документ, а живая OpenAPI-схема: генерация клиентов, контрактные тесты и мок-сервер (тестирование API локально) вырастают из неё автоматически.
Практический сценарий: проектирование ресурса «заказы»
Пройдём цикл проектирования, чтобы конвенции сложились в картину. Ресурсы: /orders (коллекция) и /orders/{id} (элемент); вложенность — /orders/{id}/items (позиции принадлежат заказу, глубина — один уровень, дальше — самостоятельный ресурс). Методы: POST /orders — создание (201 + Location, идемпотентность по ключу в заголовке — повтор сетевого ретрая не создаёт второй заказ); GET /orders?status=paid&created_after=...&sort=-created_at&page[cursor]=... — фильтр/сортировка/пагинация параметрами; PATCH /orders/{id} — частичное обновление с проверкой версии (If-Match по ETag — конкурентная запись ловится 412, а не молча затирается); POST /orders/{id}/cancel — действие вне CRUD (не DELETE — заказ не удаляется, а переходит в статус). Ошибки — единый конверт: 422 с массивом проблем по полям от валидации, 409 при конфликте статусов («нельзя отменить оплаченный»), 404 — и для чужого заказа тоже (не отдаём факт существования). Статусы переходов заказа — документированная машина состояний в описании ресурса: клиент знает, какие действия в каком статусе возможны. Всё это фиксируется OpenAPI-схемой до реализации — фронтенд пишет клиент по схеме параллельно с бэкендом, а расхождения ловятся контрактными тестами. Время, потраченное на этот дизайн-час, возвращается в первый же месяц интеграции многократно.
FAQ
REST или GraphQL для нового API? REST с OpenAPI — дефолт: проще кеш, прокси, авторизация по путям, клиенты из схемы. GraphQL оправдан при «много клиентов с разными срезами данных» (BFF-слой) и стоит своей сложности (N+1 резолверов, кеш-инвалидация, квоты запросов).
Нужен ли PUT, если есть PATCH? Для ресурсов с полной заменой — да (идемпотентен и прост). Многие API живут одним PATCH (merge-семантика) — законный выбор, если задокументирован; хуже — оба с неясной разницей.
Как вернуть ошибку создания с деталями полей? 422 + массив проблем по полям (path, code, message) — формат FastAPI/pydantic по умолчанию и совместим с RFC 7807 при небольшой обёртке; клиент показывает ошибки у конкретных полей формы.
Что отдавать на GET списка, если элементов миллион? Курсорную пагинацию с разумным максимумом limit (100) и фильтрами, сужающими выборку; «отдать всё одним ответом» — DoS для обеих сторон. Для выгрузки больших объёмов — отдельный export-эндпоинт (файл/поток), не пагинированный JSON.
Источники: RFC 9110 (HTTP Semantics), restfulapi.net, JSON:API как пример конвенций — ссылки в шапке.