API Contracts: как проектировать стабильные HTTP API
API часто начинают проектировать с endpoint:
POST /applications
Но endpoint сам по себе ещё не является API-контрактом.
Чтобы другой frontend, мобильное приложение или внешний сервис действительно мог работать с API, ему нужно знать значительно больше:
какой HTTP method использовать
какие параметры обязательны
какие headers передать
какая структура request body допустима
какой response вернётся
какие status codes возможны
как выглядят ошибки
как выполняется authentication
какие операции разрешены пользователю
Полный API-контракт можно представить так:
Request -> правила обработки -> Response
Где обе стороны заранее понимают допустимый формат взаимодействия.
Именно слово контракт здесь принципиально.
Если backend изменил поле:
program_id
на:
program
для разработчика backend это может выглядеть как небольшой рефакторинг.
Для frontend или внешней интеграции это уже потенциальный breaking change.
API является границей между системами, поэтому его структура должна проектироваться не как внутренний интерфейс функции, а как долгоживущий публичный договор между producer и consumers.
Что входит в API-контракт
Для HTTP API контракт обычно включает:
endpoint
HTTP method
path parameters
query parameters
headers
request body
response body
HTTP status codes
error schema
authentication
authorization rules
Для отдельных операций также могут быть важны:
pagination
sorting
filtering
idempotency
rate limits
content type
concurrency control
version
deprecation policy
Рассмотрим запрос:
POST /applications
Сам по себе он почти ничего не говорит.
Полноценный контракт может выглядеть концептуально так:
Method: POST
Path: /applications
Authentication: Bearer JWT
Content-Type: application/json
Request: CreateApplicationRequest
Success: 201 Created + ApplicationResponse
Validation error: 400 или 422 согласно принятому контракту API
Authentication error: 401
Authorization error: 403
Conflict: 409
И только после определения всех этих правил endpoint становится пригодным для независимой разработки consumer и backend.
Endpoint
Endpoint определяет адрес ресурса или операции.
Например:
/applications
/applications/{id}
/programs
/users/{id}/documents
Хороший endpoint обычно отражает предметную область, а не внутреннюю структуру backend.
Плохо:
/api/call_create_application_function
/api/application_service_update
/api/run_document_handler
Такие URL начинают повторять названия методов и классов серверного приложения.
Лучше:
POST /applications
PATCH /applications/{id}
POST /applications/{id}/approve
Consumer не должен знать, существует ли внутри backend:
ApplicationService
ApplicationViewSet
ApplicationRepository
Это implementation details.
API описывает возможности системы, а не структуру исходного кода.
HTTP method является частью семантики
HTTP method не просто выбирается по принципу:
GET - получить
POST - отправить
У каждого метода есть ожидаемая семантика.
Основные методы:
GET - получить представление ресурса
POST - создать ресурс или выполнить операцию
PUT - заменить представление ресурса
PATCH - частично изменить ресурс
DELETE - удалить ресурс
Например:
GET /applications/42
означает получение заявки.
PATCH /applications/42
может изменять часть её редактируемых полей.
DELETE /applications/42
может удалять ресурс, если доменная модель вообще допускает удаление.
Выбор метода влияет не только на читаемость API, но и на ожидания относительно:
- идемпотентности;
- кэширования;
- повторных запросов;
- поведения proxy;
- клиентских библиотек.
Resource-oriented API
Удобная базовая модель REST-подобного API строится вокруг ресурсов.
Например, существует ресурс:
Application
Тогда базовые операции могут выглядеть так:
GET /applications
POST /applications
GET /applications/{id}
PATCH /applications/{id}
DELETE /applications/{id}
Здесь URL обозначает объект предметной области, а HTTP method определяет действие над ним.
Такая структура хорошо масштабируется, потому что consumer быстро понимает общую логику API.
Например:
GET /programs
POST /programs
GET /programs/{id}
PATCH /programs/{id}
повторяет ту же модель.
Но не все бизнес-операции удобно выражать простым CRUD.
Когда нужны action endpoints
Представим заявку со State Machine:
Draft -> Submitted -> UnderReview -> Approved
Технически можно разрешить:
PATCH /applications/42
{
"status": "approved"
}
Но теперь consumer изменяет внутреннее состояние напрямую.
Backend должен определить:
- можно ли выполнить переход
UnderReview -> Approved; - кто имеет право его выполнить;
- проверены ли документы;
- нужно ли сохранить audit log;
- нужно ли создать участника;
- нужно ли отправить событие;
- нужно ли уведомить пользователя.
Поэтому операция:
Approve Application
часто является отдельной бизнес-командой.
Тогда API может выглядеть так:
POST /applications/42/approve
Аналогично:
POST /applications/42/submit
POST /applications/42/reject
POST /applications/42/cancel
POST /applications/42/reopen
Такой endpoint выражает не прямое изменение поля, а намерение consumer.
PATCH status = approved -> измени данные
POST /approve -> выполни бизнес-операцию Approve
В системах со сложной доменной логикой второй вариант часто делает контракт значительно яснее.
Path parameters
Path parameter идентифицирует конкретную часть ресурса.
Например:
GET /applications/42
где:
42 - application_id
В шаблоне:
/applications/{id}
Контракт должен определить:
- тип параметра;
- допустимый формат;
- семантику;
- что произойдёт, если ресурс отсутствует.
Например:
id: integer
minimum: 1
или:
id: UUID
Если объект не существует:
404 Not Found
Query parameters
Query parameters чаще используются для изменения способа получения коллекции.
Например:
GET /applications?status=under_review
или:
GET /applications?program_id=42&status=approved
Они подходят для:
- фильтрации;
- сортировки;
- пагинации;
- поиска;
- выбора представления.
Например:
GET /applications?status=approved
GET /applications?ordering=-created_at
GET /applications?page=2
Контракт должен определить не только наличие параметра, но и:
тип
обязательность
допустимые значения
default
поведение при неправильном значении
возможность передавать несколько значений
Например:
status: enum[draft, submitted, under_review, approved, rejected]
required: false
Path parameter и query parameter решают разные задачи
Обычно path определяет ресурс:
/applications/{id}
а query изменяет выборку или представление:
/applications?status=approved
Плохая конструкция:
/applications?id=42
не всегда является ошибкой технически, но если id определяет конкретный ресурс, форма:
/applications/42
обычно лучше выражает модель API.
Headers
Headers являются частью контракта не меньше JSON body.
Например:
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json
Могут существовать и дополнительные headers:
Idempotency-Key
If-Match
X-Request-ID
Но собственные headers стоит добавлять только тогда, когда у них действительно есть системный смысл.
Например, Idempotency-Key может использоваться для защиты операции создания платежа от повторного выполнения.
POST /payments
Idempotency-Key: 91a4...
Если клиент повторит запрос из-за network timeout, backend сможет определить, что операция уже была обработана.
Request Body
Request body содержит данные операции.
Например:
POST /applications
Content-Type: application/json
{
"program_id": 42,
"comment": "Хочу участвовать в программе"
}
Но схема должна описывать значительно больше, чем пример JSON.
Необходимо определить:
какие поля существуют
какие обязательны
какие допускают null
какие типы значений разрешены
какие ограничения действуют
какие enum существуют
можно ли передавать неизвестные поля
Например:
program_id
type: integer
required: true
comment
type: string
required: false
nullable: true
maxLength: 2000
Required и nullable - разные вещи
Это принципиальная деталь контракта.
Представим поле:
comment
required: false означает:
Поле можно вообще не отправлять.
Например:
{}
nullable: true означает:
Поле можно отправить со значением null.
{
"comment": null
}
Это разные состояния:
поле отсутствует
поле присутствует и равно null
поле присутствует и содержит значение
Особенно важно различать их в PATCH.
Например:
{}
может означать:
Не изменять comment.
А:
{
"comment": null
}
может означать:
Очистить comment.
Если контракт не определяет эту разницу, frontend и backend могут интерпретировать один запрос по-разному.
Enum
Если поле имеет конечный набор значений, это должно быть частью схемы.
Например:
{
"status": "under_review"
}
Контракт:
status:
draft
submitted
under_review
approved
rejected
Не стоит ограничиваться:
type: string
если фактически произвольная строка недопустима.
Enum позволяет:
- валидировать запрос;
- генерировать клиентские типы;
- отображать возможные значения в документации;
- раньше обнаруживать ошибки интеграции.
Но изменение enum тоже требует осторожности.
Добавление нового значения может формально выглядеть как additive change, но consumer с кодом:
if status == "draft":
...
elif status == "approved":
...
else:
raise RuntimeError()
может сломаться на новом значении.
Поэтому совместимость определяется не только JSON Schema, но и поведением consumers.
Nested objects
Схемы могут содержать вложенные структуры.
Например:
{
"program_id": 42,
"applicant": {
"first_name": "Sergey",
"last_name": "Kitov"
}
}
Но чрезмерная вложенность усложняет:
- частичные обновления;
- validation errors;
- сериализацию;
- повторное использование объектов;
- изменение контракта.
Нужно различать:
вложенное представление существующего ресурса
и:
создание нескольких ресурсов одной операцией
Это разные задачи.
Lists
Коллекция должна иметь предсказуемую структуру.
Простейший вариант:
[
{
"id": 1,
"status": "approved"
},
{
"id": 2,
"status": "under_review"
}
]
Но если API использует pagination, удобнее вернуть объект:
{
"count": 125,
"next": "/applications?page=3",
"previous": "/applications?page=1",
"results": [
{
"id": 42,
"status": "approved"
}
]
}
Так контракт коллекции можно расширять дополнительными metadata без изменения корневого типа ответа.
Request schema и Response schema не обязаны совпадать
Одна из частых ошибок:
ApplicationSerializer используется вообще для всего
Но модель создания и модель чтения часто имеют разные требования.
Create request:
{
"program_id": 42,
"comment": "..."
}
Response:
{
"id": 381,
"program": {
"id": 42,
"name": "Data Science"
},
"status": "draft",
"comment": "...",
"created_at": "2026-09-29T10:42:00Z"
}
Consumer не должен передавать:
id
created_at
status
если эти значения контролирует сервер.
Поэтому полезно мыслить отдельными моделями:
CreateApplicationRequest
UpdateApplicationRequest
ApplicationResponse
ApplicationListItem
Даже если внутри DRF часть serializers переиспользуется.
Validation
Validation является частью API-контракта.
Например:
program_id должен существовать
comment <= 2000 символов
start_date <= end_date
email должен иметь допустимый формат
Есть два уровня validation.
Первый - структурный:
поле обязательное
тип integer
значение <= 100
Второй - бизнесовый:
нельзя подать вторую активную заявку на ту же программу
программа должна быть открыта для регистрации
заявку нельзя отправить после deadline
Оба должны иметь предсказуемое представление в ошибках.
DRF Serializer и API-контракт
В Django REST Framework Serializer часто выполняет несколько задач:
парсинг input
validation
преобразование Python objects
формирование output
Например:
class CreateApplicationSerializer(serializers.Serializer):
program_id = serializers.IntegerField()
comment = serializers.CharField(
required=False,
allow_null=True,
max_length=2000,
)
Этот код уже содержит часть API-контракта:
program_id -> integer, required
comment -> string, optional, nullable, max 2000
Но Serializer не должен автоматически считаться полной спецификацией API.
Он ничего сам по себе не говорит о:
- URL;
- HTTP method;
- status codes;
- authentication;
- authorization;
- версии API;
- retry semantics;
- idempotency;
- error model.
Поэтому API-контракт шире DRF Serializer.
Response
Successful response тоже требует явного контракта.
Например:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": 42,
"status": "draft",
"program_id": 7,
"created_at": "2026-09-29T10:00:00Z"
}
Нужно определить:
какие поля гарантированы
какие могут отсутствовать
может ли поле быть null
какие форматы дат используются
как представляются money values
как передаются identifiers
Даже простое решение вроде даты должно быть частью единого соглашения.
Например:
ISO 8601 timestamp в UTC
лучше, чем несколько endpoint, возвращающих:
29.09.2026
2026-09-29
2026-09-29T10:00:00Z
для одинакового типа данных без причины.
HTTP status codes
HTTP уже содержит стандартный механизм передачи класса результата операции.
Не стоит возвращать:
200 OK
на любую ситуацию, а затем кодировать ошибку только внутри JSON.
Например:
{
"success": false,
"error": "not found"
}
с HTTP 200.
Это лишает инфраструктуру части семантики HTTP.
Основные коды API обычно используются примерно так.
200 OK
Операция успешно выполнена и есть response body.
GET resource
PATCH resource
201 Created
Создан новый ресурс.
POST /applications
Полезно также возвращать созданное представление ресурса.
204 No Content
Операция успешно выполнена, но response body не требуется.
Например, некоторые DELETE.
400 Bad Request
Запрос не может быть обработан из-за ошибки клиента.
Например:
невалидный JSON
неверные параметры
нарушение принятой схемы validation
Конкретная граница между 400 и 422 зависит от выбранной политики API.
401 Unauthorized
Несмотря на историческое название, практически означает:
Authentication отсутствует или невалидна.
Например:
Bearer token отсутствует
JWT истёк
подпись token не прошла проверку
403 Forbidden
Authentication уже известна, но операция запрещена.
Например:
обычный оператор пытается выполнить approve, доступный только manager
404 Not Found
Ресурс отсутствует или намеренно не раскрывается этому consumer согласно политике API.
409 Conflict
Запрос конфликтует с текущим состоянием ресурса.
Например:
application уже approved
а consumer вызывает:
POST /applications/42/approve
Другой пример:
создание ресурса нарушает уникальное бизнес-ограничение
422 Unprocessable Content
Может использоваться, когда синтаксически запрос корректен, но данные не проходят семантическую validation.
Например:
start_date > end_date
Но важно выбрать единый подход.
Не стоит одному endpoint возвращать validation через 400, второму через 422, а третьему через 200 с полем error.
500 Internal Server Error
Неожиданная ошибка server-side.
Consumer не должен получать traceback, SQL query или внутренние технические детали.
Ошибка тоже является частью контракта
API часто подробно проектирует successful response и почти не проектирует ошибки.
В результате один endpoint отвечает:
{
"error": "Invalid request"
}
другой:
{
"detail": "Not found."
}
третий:
{
"message": "Something went wrong",
"status": false
}
Frontend приходится поддерживать несколько форматов.
Лучше определить единую error schema.
Например:
{
"code": "application_invalid_state",
"message": "Application cannot be approved",
"details": {
"current_state": "draft",
"required_state": "under_review"
}
}
Здесь:
code - стабильный машинный идентификатор
message - сообщение для разработчика или интерфейса
details - структурированные дополнительные данные
Можно также использовать:
request_id
field_errors
Например:
{
"code": "validation_error",
"message": "Request contains invalid fields",
"details": {
"fields": {
"email": [
"Invalid email format"
]
}
},
"request_id": "req_7f12..."
}
Frontend может использовать code, не пытаясь анализировать текст message.
Error code и HTTP status решают разные задачи
HTTP status:
409
говорит:
Запрос конфликтует с текущим состоянием.
Application error code:
application_invalid_state
говорит:
Какой именно конфликт произошёл.
Поэтому нормальная модель использует оба уровня:
HTTP status -> общий класс результата
application code -> конкретная бизнес-причина
Не привязывайте consumer к тексту ошибки
Плохо:
if (
response.message ===
"Application cannot be approved"
) {
...
}
Любое изменение текста сломает логику.
Лучше:
if (
response.code ===
"application_invalid_state"
) {
...
}
message можно локализовать или улучшать, не ломая программный контракт.
OpenAPI
Когда контракт становится достаточно большим, описывать его только prose-документацией неудобно.
Для HTTP API широко используется OpenAPI Specification.
OpenAPI позволяет машиночитаемо описать:
- paths;
- operations;
- parameters;
- request bodies;
- response schemas;
- status codes;
- authentication schemes;
- reusable schemas.
Упрощённый пример:
paths:
/applications/{id}:
get:
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Application
content:
application/json:
schema:
$ref: "#/components/schemas/Application"
"404":
description: Application not found
Это уже не просто текстовая документация.
OpenAPI может использоваться для:
- генерации документации;
- валидации;
- генерации client SDK;
- создания mocks;
- contract testing;
- IDE tooling;
- автоматизации интеграций.
OpenAPI и Swagger UI - не одно и то же
Эти термины часто смешивают.
Разница:
OpenAPI - спецификация API-контракта
Swagger UI - интерфейс для визуального просмотра и выполнения запросов по OpenAPI
То есть OpenAPI может существовать без Swagger UI.
Например:
openapi.yaml
является контрактом.
Swagger UI может прочитать этот файл и построить web-интерфейс.
Поэтому фраза:
У нас документация API сделана в Swagger.
часто означает:
У нас есть OpenAPI specification, которую показывает Swagger UI.
Но Swagger UI является только одним из способов представить спецификацию человеку.
OpenAPI не гарантирует хороший API
Можно идеально описать плохой API.
Например:
POST /executeFunction1
POST /executeFunction2
POST /updateApplicationEverything
и получить полностью валидный OpenAPI document.
OpenAPI фиксирует контракт.
Она не принимает архитектурные решения вместо разработчика.
Поэтому правильная последовательность:
спроектировать API -> определить контракт -> формализовать его в OpenAPI
а не:
сгенерировать Swagger -> считать API спроектированным
Contract-first и code-first
Есть два распространённых подхода.
Code-first
Сначала создаётся backend:
DRF ViewSet
Serializer
routes
а OpenAPI генерируется из кода.
Плюс:
спецификация ближе к реальной реализации
Минус:
API легко начинает повторять удобную для backend внутреннюю структуру
Contract-first
Сначала проектируется:
OpenAPI contract
затем backend и consumer реализуют его независимо.
Это удобно для команд, где:
frontend
backend
mobile
external integrations
должны работать параллельно.
На практике возможен смешанный подход, но источник истины должен быть понятен.
Если OpenAPI говорит одно, а реальный backend делает другое, контракт перестаёт выполнять свою функцию.
Authentication
Authentication отвечает на вопрос:
Кто выполняет запрос?
Например:
Authorization: Bearer <access_token>
Backend проверяет token и получает identity:
user_id
subject
roles
claims
Если используется Keycloak, типичный поток может выглядеть так:
User -> Keycloak -> access token -> API
API -> проверка token -> identity
API не должен доверять token только потому, что consumer его прислал.
Необходимо проверить как минимум применимые свойства токена согласно выбранной схеме:
signature
expiration
issuer
audience
Authorization
Authorization отвечает на другой вопрос:
Разрешено ли этому identity выполнить конкретную операцию?
Например:
operator -> review application
manager -> approve application
administrator -> manage program
Пользователь может быть успешно authenticated, но не иметь права выполнить:
POST /applications/42/approve
Тогда:
403 Forbidden
Кратко:
Authentication - кто ты?
Authorization - можно ли тебе это делать?
В DRF authorization может реализовываться через:
permission classes
object-level permissions
service-level business checks
И контракт должен хотя бы на уровне документации фиксировать требуемые права операции.
Role недостаточно для всей авторизации
Например:
role = manager
может разрешать approve.
Но конкретный manager может иметь доступ только к своей образовательной программе.
Тогда проверка становится:
role == manager
+
application.program in manager.allowed_programs
Это object-level authorization.
Поэтому API-контракт может описывать операцию:
Requires role: manager
Requires access to application program
а backend обязан проверять оба условия.
API и State Machine
API особенно тесно связан с жизненным циклом сущностей.
Например:
POST /applications/{id}/approve
должен учитывать:
current state
actor
guard conditions
allowed transition
Обработка может выглядеть так:
получить Application -> проверить authentication -> проверить authorization -> проверить transition -> изменить state -> commit -> инициировать side effects -> вернуть response
API здесь является только точкой входа в доменную операцию.
Правила State Machine не должны существовать исключительно в ViewSet.
Иначе тот же переход, вызванный из Celery task или другого сервиса, может обойти проверки.
Идемпотентность
Идемпотентная операция при повторном выполнении имеет тот же конечный эффект.
GET должен быть safe и idempotent по своей семантике.
PUT и DELETE проектируются как idempotent.
POST обычно не обязан быть идемпотентным.
Например:
POST /payments
выполненный дважды потенциально создаст два платежа.
Но network failure создаёт проблему:
client отправил запрос -> server создал payment -> response потерялся -> client повторил запрос
Без дополнительного механизма:
payment_1
payment_2
Для критичных операций можно использовать idempotency key.
Idempotency-Key: 93ae...
Backend связывает ключ с выполненной операцией и повторно не создаёт ресурс.
Это тоже часть контракта.
Consumer должен знать:
где обязателен idempotency key
сколько он действует
что произойдёт при повторном запросе
Конкурентное изменение ресурса
Представим двух операторов.
Оба получили:
Application version 5
Первый изменил данные и сохранил version 6.
Второй продолжает работать со старой version 5 и отправляет PATCH.
Без дополнительной защиты его изменение может перезаписать данные первого.
Для optimistic concurrency control можно использовать version field или HTTP mechanisms вроде ETag.
Концептуально:
GET -> ETag: "v5"
PATCH -> If-Match: "v5"
Если ресурс уже изменился:
current ETag = v6
сервер отклоняет обновление.
Подобные правила особенно важны для collaborative systems и должны быть частью контракта, а не случайной деталью frontend.
Версионирование API
API почти неизбежно меняется.
Главная задача версионирования не в том, чтобы добавить:
/v1/
к URL.
Главная задача:
Управлять совместимостью между producer и существующими consumers.
Представим response:
{
"id": 42,
"status": "approved"
}
Если добавить поле:
{
"id": 42,
"status": "approved",
"approved_at": "2026-09-29T10:00:00Z"
}
для большинства корректно написанных consumers это additive, backward-compatible change.
Если удалить status:
{
"id": 42
}
это breaking change.
Backward-compatible изменения
Часто безопаснее:
добавить optional response field
добавить новый endpoint
добавить новый optional request parameter
Но даже additive change нужно оценивать относительно реальных consumers.
Например, новый enum value теоретически добавляет возможность, но старый клиент может не уметь его обработать.
Поэтому схема не даёт абсолютной гарантии совместимости.
Breaking changes
Типичные breaking changes:
удалить поле
переименовать поле
изменить тип поля
сделать optional request field обязательным
изменить смысл существующего значения
изменить структуру response
изменить HTTP status code, на который полагается consumer
удалить endpoint
Если change breaking, нужны:
новая версия
migration path
deprecation period
обновление consumers
Version in URL
Один из вариантов:
/api/v1/applications
/api/v2/applications
Преимущества:
версию видно явно
просто маршрутизировать
понятно consumer
Недостаток:
версия становится частью resource URL
Version in header
Другой подход:
Accept: application/vnd.example.v2+json
или отдельный version header согласно внутренней политике API.
URL остаётся стабильным, но механизм становится менее очевидным при обычном просмотре.
Нет универсального правила, что всегда лучше.
Важнее:
иметь одну понятную стратегию
+
не использовать версию как замену нормальной compatibility discipline
Если /v2 создаётся при каждом добавлении одного optional field, версионирование тоже становится проблемой.
Deprecation
Удалять старую версию без переходного периода опасно.
Нормальный процесс:
новый contract -> объявить старый deprecated -> перевести consumers -> проверить использование -> удалить старый contract
Особенно важно для внешних API, где producer не контролирует consumer.
Для внутренних API ситуация проще, но принцип остаётся тем же.
Consumer-driven thinking
При изменении API недостаточно смотреть только со стороны backend.
Нужно знать:
кто использует endpoint
какие поля consumer читает
какие assumptions он делает
какие status codes обрабатывает
может ли он принимать дополнительные поля
как быстро он может обновиться
Это и есть consumer-oriented взгляд на контракт.
Например, backend-команда считает безопасным:
добавить новый enum value
Но мобильное приложение использует exhaustive switch.
Тогда фактически изменение может оказаться breaking.
Consumer-driven contract testing
Идею можно развить дальше.
Consumer может формализовать свои ожидания:
я вызываю GET /applications/42
я ожидаю 200
я использую поля id и status
status должен быть одним из известных значений
Producer автоматически проверяет, что новая версия API сохраняет эти ожидания.
Это называют consumer-driven contract testing.
Такой подход особенно полезен, когда одна backend-система имеет много независимых consumers.
Контракт перестаёт быть только документацией и начинает автоматически проверяться в CI.
Webhook тоже является API-контрактом
Webhook меняет направление взаимодействия.
В обычном API:
Consumer -> Provider
В webhook:
Provider -> Consumer
Например:
CRM -> POST https://partner.example/webhooks/application
Но требования к контракту остаются.
Нужно определить:
event type
payload
headers
signature
timestamp
version
retry policy
idempotency
response expectations
Пример:
{
"event_id": "evt_123",
"event_type": "application.approved",
"occurred_at": "2026-09-29T10:15:00Z",
"data": {
"application_id": 42
}
}
Event type
event_type должен иметь стабильную семантику.
Например:
application.created
application.submitted
application.approved
application.rejected
Consumer подписывается на конкретные события.
Не стоит использовать:
application.updated
для абсолютно всех изменений, если consumer затем вынужден загружать объект и самостоятельно выяснять, что изменилось.
Гранулярность зависит от сценария, но контракт должен быть осознанным.
Event ID и idempotency
Webhook может быть доставлен повторно.
Например:
provider отправил webhook
consumer обработал
response потерялся
provider выполнил retry
Consumer получает одно событие дважды.
Поэтому полезен:
event_id
Consumer может хранить обработанные значения и обеспечить:
одно logical event -> один business effect
Даже если транспорт доставил его несколько раз.
Webhook retries
Контракт должен определять:
какой response считается успешным
какие ошибки приводят к retry
сколько попыток выполняется
какой backoff используется
как долго событие может повторяться
Consumer должен проектироваться с ожиданием повторной доставки, если provider использует at-least-once модель.
Webhook signature
Поскольку webhook приходит извне, consumer должен иметь возможность проверить источник.
Обычно используется подпись.
Концептуально:
payload + secret -> signature
Consumer получает:
raw request body
signature header
и проверяет подпись до обработки события.
При проектировании нужно зафиксировать:
алгоритм
формат signature
какие bytes подписываются
timestamp
защиту от replay
ротацию secret
Иначе две команды могут реализовать "HMAC webhook" несовместимыми способами.
Webhook versioning
Webhook изменить сложнее, чем внутренний API.
Provider не контролирует скорость обновления всех receivers.
Поэтому полезно заранее продумать:
schema version
event version
backward-compatible additions
migration period
Например:
{
"version": "1",
"event_type": "application.approved",
"data": {}
}
Версия может находиться и на другом уровне контракта, но стратегия должна быть единой.
API и внешние идентификаторы
Внутренний Primary Key базы:
application.id = 42
не всегда обязан становиться публичным API identifier.
Иногда это нормально.
Но если внутренний ID является чистой implementation detail, внешний контракт может использовать:
UUID
public_id
business identifier
Причина не в том, что integer ID сам по себе плох.
Проблема возникает, когда внешний consumer начинает зависеть от детали, которую backend позже хочет изменить.
Публичный identifier становится частью долгоживущего контракта.
Его стоит проектировать осознанно.
API не должен раскрывать внутреннюю модель базы напрямую
Представим таблицу:
application
id
student_id
program_id
status_id
created_by_id
Это не означает, что API response должен быть точной копией таблицы:
{
"id": 42,
"student_id": 5,
"program_id": 7,
"status_id": 3,
"created_by_id": 12
}
Публичная API model и database model решают разные задачи.
API может вернуть:
{
"id": "app_91f...",
"program": {
"id": 7,
"name": "Data Science"
},
"status": "under_review"
}
Так backend получает свободу изменять внутреннюю схему без обязательного изменения contract.
API и транзакционные границы
API operation должна иметь понятную atomicity.
Например:
POST /applications
создаёт:
Application
Document records
Audit event
Если половина операции завершилась, а половина нет, что увидит consumer?
Для критичных операций полезно определить:
что считается успешно завершённой operation
Например:
201 Created возвращается только после commit основной транзакции
Асинхронные действия:
email
webhook
PDF generation
могут продолжиться позже.
Тогда response отражает состояние основной операции, а не завершение всего downstream processing.
Синхронная и асинхронная операция
Не каждая операция может завершиться за один HTTP request.
Например:
Generate large report
Вместо:
POST /reports/generate -> держать connection 3 минуты
можно использовать:
POST /reports
202 Accepted
{
"id": "report_123",
"status": "processing"
}
Затем:
GET /reports/report_123
возвращает:
{
"id": "report_123",
"status": "completed",
"download_url": "..."
}
Контракт должен явно говорить, что операция асинхронная.
202 Accepted не означает:
Работа успешно завершена.
Он означает:
Запрос принят для дальнейшей обработки.
API contract и observability
Хороший контракт помогает и при эксплуатации.
Например, каждый request может иметь:
request_id
Он связывает:
frontend error -> API logs -> Celery logs -> external request
Если API возвращает:
{
"code": "internal_error",
"message": "Unexpected server error",
"request_id": "req_92fe..."
}
пользователь не получает внутренний traceback, но разработчик может найти конкретный запрос в логах.
Это особенно полезно в распределённых системах.
Что должно быть источником истины
У команды не должно существовать три разных версии API:
реальный backend
Swagger UI
Confluence
которые описывают разные контракты.
Должен быть понятный source of truth.
Например:
OpenAPI specification
из которой строится документация и относительно которой проверяется реализация.
Или code-first pipeline:
DRF code -> generated OpenAPI -> CI validation
Главное, чтобы расхождение обнаруживалось автоматически.
Как проектировать API-контракт с нуля
Для нового endpoint удобно идти в таком порядке.
Сначала определить бизнес-операцию:
Approve Application
Затем определить ресурс:
Application
Выбрать форму операции:
POST /applications/{id}/approve
Определить authentication:
Bearer token
Определить authorization:
manager with access to application program
Определить preconditions:
current_state = under_review
documents_verified = true
Определить request:
{
"comment": "Documents verified"
}
Определить successful response:
200 OK
{
"id": 42,
"status": "approved",
"approved_at": "2026-09-29T10:00:00Z"
}
Определить ошибки:
401 - authentication failed
403 - insufficient permissions
404 - application not found
409 - invalid current state
422 - invalid request according to chosen validation policy
500 - unexpected server error
Определить side effects:
ApplicationApproved event
notification
participant creation
Затем описать всё это в OpenAPI и добавить contract tests.
Общий путь:
business operation -> resource -> endpoint -> request schema -> rules -> response schema -> errors -> security -> OpenAPI -> tests
Типичные ошибки API design
Первая ошибка - endpoint повторяет имя backend-функции.
Плохо:
POST /executeApproveApplication
POST /getAllApplications
Лучше моделировать внешний contract независимо от внутреннего кода.
Вторая ошибка - HTTP status codes не используются по назначению.
Если любой ответ:
200
consumer теряет стандартную семантику HTTP.
Третья ошибка - несколько форматов ошибок.
{"error": "..."}
{"detail": "..."}
{"message": "..."}
в одной API увеличивают сложность каждого consumer.
Четвёртая ошибка - consumer зависит от внутренних идентификаторов и структуры базы без необходимости.
Пятая ошибка - response является прямой сериализацией ORM model.
Изменение базы начинает автоматически становиться изменением публичного API.
Шестая ошибка - authentication смешивается с authorization.
JWT валиден
не означает:
операция разрешена
Седьмая ошибка - status изменяется как обычное поле без проверки State Machine.
Восьмая ошибка - контракт меняется без анализа существующих consumers.
Девятая ошибка - OpenAPI существует, но не соответствует реальному backend.
Десятая ошибка - Swagger UI считают всей API-документацией.
Swagger UI хорошо показывает операции и schemas, но часто не объясняет:
- бизнес-смысл;
- жизненный цикл ресурсов;
- ограничения между операциями;
- retry semantics;
- migration rules;
- сложные интеграционные сценарии.
Один OpenAPI document не заменяет архитектурное объяснение API.
Как проверить API-контракт
Перед публикацией нового endpoint полезно проверить:
- Понятен ли ресурс или бизнес-смысл операции?
- Подходит ли HTTP method?
- Разделены ли path и query parameters?
- Определены ли required и nullable поля?
- Явно ли заданы enum?
- Разделены ли request и response models там, где это нужно?
- Определены ли все значимые status codes?
- Есть ли единый error format?
- Стабильны ли machine-readable error codes?
- Определена ли authentication?
- Определена ли authorization?
- Проверяются ли object-level permissions?
- Нужна ли idempotency?
- Может ли возникнуть concurrent update?
- Понятна ли async semantics?
- Определена ли compatibility policy?
- Является ли изменение breaking?
- Известны ли существующие consumers?
- Соответствует ли OpenAPI реальной реализации?
- Есть ли contract tests?
Если на эти вопросы нет ответов, endpoint технически может работать, но контракт остаётся недоопределённым.
Что важно запомнить
API-контракт значительно шире endpoint.
Минимальная модель:
Method + URL + Parameters + Headers + Request Schema + Response Schema + Status Codes + Errors + Security
Но стабильный production API дополнительно требует думать о:
compatibility
versioning
idempotency
concurrency
authorization
webhooks
consumers
deprecation
contract testing
Ресурсный API обычно строится вокруг:
GET /applications
POST /applications
GET /applications/{id}
PATCH /applications/{id}
Сложные бизнес-переходы могут использовать action endpoints:
POST /applications/{id}/approve
Request schema и response schema не обязаны совпадать.
HTTP status и application error code выполняют разные функции:
409 Conflict -> общий класс ошибки
application_invalid_state -> конкретная причина
OpenAPI формализует контракт:
OpenAPI -> машиночитаемая specification
Swagger UI -> интерфейс для её просмотра
Authentication и authorization также нельзя смешивать:
Authentication -> кто выполняет запрос
Authorization -> имеет ли он право выполнить операцию
Webhook является таким же контрактом, только инициатор взаимодействия меняется:
Provider -> Consumer
А главная проверка любого изменения API звучит так:
что изменилось в producer -> какие assumptions существуют у consumers -> сохранится ли их работа после изменения
Именно поэтому хороший API проектируется как долгоживущая граница между системами, а не как случайный набор HTTP routes вокруг backend-кода.