В Python API почти всегда рождается из кода: FastAPI собирает OpenAPI из аннотаций типов и моделей Pydantic, и описание появляется само, по адресу /openapi.json. Это удобно, и именно поэтому API-first здесь требует отдельной дисциплины: ничто в языке не мешает обработчику вернуть поле или код, которых в контракте нет. Контракт в Python держится не компилятором, а генератором моделей и тестом, который сверяет ответы со схемой.
Что такое API-first простыми словами
API-first — подход, при котором контракт API проектируется первым, до реализации, и становится главным артефактом, по которому работают все стороны. Контракт — машиночитаемое описание: какие есть маршруты, какие параметры и тела запросов, какие ответы и коды ошибок. Стандартный формат для REST — OpenAPI (YAML или JSON).
Короткая формула: сначала контракт, потом код. Контракт — договор между теми, кто API предоставляет, и теми, кто его потребляет.
Противоположность — code-first: пишем обработчики, а описание получаем из них. В Python это путь по умолчанию: FastAPI, Litestar и Django Ninja строят схему из сигнатур и моделей. Оба варианта дают на выходе OpenAPI-документ, но порядок и источник правды разные.
Зачем это нужно
Фронтенд ждёт три недели, пока бэкенд допишет маршрут, а потом ещё неделю переделывает под то, что получилось не так, как договаривались на словах. Контракт, готовый раньше кода, убирает обе недели: фронт поднимает мок по спецификации и пишет интерфейс, пока бэкенд реализует логику.
Контракт один и машиночитаемый, поэтому не бывает «в коде одно, в документации другое»: документация, модели и клиент генерируются из него же. Дизайн обсуждают на ревью YAML до первой строчки реализации, а поправить YAML дешевле, чем переписывать готовый код. И контракт сравнивают между версиями автоматически, ловя ломающие изменения в сборке.
Contract-first и code-first
Оба пути ведут к OpenAPI-документу, но по-разному.
При code-first фронтенд ждёт готового бэкенда; при contract-first обе стороны работают от одного файла одновременно.
Contract-first — источник правды это OpenAPI YAML. Сначала пишем спецификацию руками, затем datamodel-codegen создаёт из неё модели Pydantic, а openapi-python-client — типизированного клиента на httpx. Обработчики FastAPI принимают и возвращают сгенерированные модели, а тест на Schemathesis сверяет каждый ответ со схемой.
# фрагмент контракта: контракт первичен
paths:
/orders/{id}:
get:
operationId: getOrder
tags: [orders]
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Заказ
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
"404":
description: Нет заказа
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Code-first — источник правды это код. FastAPI читает сигнатуру обработчика (id: uuid.UUID, body: CreateOrder, response_model=Order) и собирает openapi.json сам, без комментариев и аннотаций. Быстрее на старте и честнее, чем комментарии в других стеках: схема строится из тех же типов, которые проверяют запрос. Но контракт здесь — следствие кода, а не договор: переименовали поле модели — изменился и контракт, и никто этого не обсуждал.
Что выбрать: для публичного API и для нескольких команд, которым нужно договориться заранее, обычно лучше contract-first. Для небольшого внутреннего сервиса, который пишет одна команда, code-first на FastAPI проще и его достаточно.
Как выглядит процесс contract-first
- Пишем/правим OpenAPI-контракт — маршруты, схемы, ошибки, версии.
- Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
- Генерация —
datamodel-codegenсоздаёт модели,openapi-python-clientклиента; результат коммитится. - Реализация — обработчики принимают и возвращают сгенерированные модели; сгенерированный файл не правится руками.
- Проверка в сборке — линт контракта, сравнение с предыдущей версией, свежесть моделей, прогон ответов по схеме.
Важное правило команды: правки контракта идут в YAML, а не в сгенерированный код.
Как это собирается: datamodel-codegen
Описание лежит в репозитории сервиса, рядом с кодом, который его реализует: api/openapi.yaml. Генератор настраивают в pyproject.toml, чтобы настройки были видны на ревью, а команда у всех была одна:
[tool.datamodel-codegen]
input = "api/openapi.yaml"
input-file-type = "openapi"
output = "app/api/models.py"
output-model-type = "pydantic_v2.BaseModel"
target-python-version = "3.12"
use-annotated = true
uv run datamodel-codegen
Из контракта выше получается обычный файл с моделями:
# generated by datamodel-codegen:
# filename: openapi.yaml
from __future__ import annotations
from enum import StrEnum
from typing import Annotated
from uuid import UUID
from pydantic import BaseModel, Field
class Status(StrEnum):
DRAFT = 'DRAFT'
CONFIRMED = 'CONFIRMED'
class Order(BaseModel):
id: UUID
status: Status
qty: Annotated[int, Field(ge=1)]
class CreateOrder(BaseModel):
productId: Annotated[str, Field(min_length=1)]
qty: Annotated[int, Field(ge=1)]
Три вещи, которые здесь важны. Ограничения схемы — minimum, minLength, enum — превратились в Field(ge=1), Field(min_length=1) и StrEnum, то есть проверку запроса генератор даёт бесплатно: Pydantic отвергнет qty: 0 до обработчика. Имена полей остались как в контракте (productId); флаг --snake-case-field даёт product_id с псевдонимом, если в коде хочется писать по-питоновски. И шапка generated by datamodel-codegen — единственное, что отличает этот файл от написанного руками: редактор и ревьюер её видят, но инструменты на неё не смотрят.
Как выглядит реализация. Обработчик FastAPI принимает и возвращает сгенерированные модели, а имя операции берёт из контракта:
from fastapi import FastAPI, Header, status
from fastapi.routing import APIRoute
from app.api.models import CreateOrder, Order, Problem
def operation_id(route: APIRoute) -> str:
return route.name
app = FastAPI(title="Orders", version="1.0.0", generate_unique_id_function=operation_id)
@app.get(
"/orders/{id}",
name="getOrder",
tags=["orders"],
response_model=Order,
responses={404: {"model": Problem, "description": "Нет заказа"}},
)
async def get_order(id: uuid.UUID, orders: OrdersDep) -> Order:
return to_api(await orders.by_id(id))
@app.post("/orders", name="createOrder", tags=["orders"], response_model=Order, status_code=status.HTTP_201_CREATED)
async def create_order(body: CreateOrder, create: CreateOrderDep, idempotency_key: str = Header(alias="Idempotency-Key")) -> Order:
return to_api(await create.handle(to_command(body), idempotency_key))
generate_unique_id_function нужна, потому что по умолчанию FastAPI называет операцию get_order_orders__id__get, и клиент, сгенерированный из /openapi.json, получит метод с таким именем. С name="getOrder" имя операции совпадает с контрактом. Параметр responses описывает 404 для документации: без него FastAPI знает только про 200 и автоматический 422. Тип содержимого ответа при этом FastAPI запишет как application/json; что отдавать application/problem+json, знает контракт, и проверять это будет тест по контракту, а не схема из кода.
Теперь главное отличие от стеков с генерацией интерфейса сервера: ничто не мешает обработчику нарушить контракт. Можно вернуть JSONResponse(status_code=409), которого в описании нет, можно отдать status="NEW" вместо значения из перечисления, можно забыть заголовок. Компилятора, который откажется собирать такой код, в Python нет. Его роль берёт на себя тест по схеме, и без него contract-first заканчивается на генерации моделей.
Проверка ответов по схеме. Schemathesis читает контракт, генерирует запросы под каждую операцию и сверяет ответы с описанием: код ответа есть в контракте, тип содержимого совпадает, тело проходит по схеме. Запускают против поднятого сервиса или прямо против ASGI-приложения в pytest:
import schemathesis
from hypothesis import settings
from app.main import app
schema = schemathesis.openapi.from_path("api/openapi.yaml")
schema.app = app
@schema.parametrize()
@settings(max_examples=50, deadline=None)
def test_contract(case):
case.call_and_validate(headers={"Idempotency-Key": "test"})
Обработчик, вернувший status: "NEW", роняет тест с понятным текстом:
Response violates schema
"NEW" is not one of "DRAFT" or "CONFIRMED"
Validated against the response schema for status code 201.
Та же проверка находит код ответа, которого нет в контракте (Undocumented HTTP status code), и не тот Content-Type. Первое, что она находит в живом сервисе, — автоматический 422 FastAPI на неверном теле: в контракте его обычно нет, и это честный повод описать ошибки валидации явно, в вашем формате, как в статье про ошибки REST API.
Клиент из того же описания. openapi-python-client generate --path api/openapi.yaml создаёт пакет с моделями и функцией на каждую операцию: get_order.sync_detailed(client=client, id=order_id) возвращает разобранный Order на 200 и Problem на 404, а на код, которого в контракте нет, бросает UnexpectedStatus. Потребитель на Python не пишет обращения руками, а клиент меняется вместе с контрактом.
Строгий путь: сервер по спецификации
Есть и вариант, в котором контракт держит сервер сам: connexion читает OpenAPI-файл, по operationId вида orders.get_order находит функцию, проверяет каждый запрос по схеме и, с validate_responses=True, каждый ответ.
from connexion import AsyncApp
app = AsyncApp(__name__)
app.add_api("api/openapi.yaml", strict_validation=True, validate_responses=True)
Запрос с qty: 0 получает 400 с телом application/problem+json и текстом 0 is less than the minimum of 1 - 'qty', запрос без обязательного заголовка — Missing header parameter 'Idempotency-Key', а ответ не по схеме — 500 прямо на этапе разработки. Это ближе всего к тому, что в других стеках делает генерация интерфейса сервера. Цена: функции получают разобранные словари, а не модели, экосистема меньше, чем у FastAPI, и формат ошибок валидации задаёт библиотека. Берут его, когда контракт действительно главный, а сервис невелик.
Первая ловушка: сгенерированный код коммитят
Сгенерированные модели кладут в репозиторий, а не собирают на лету: так они видны на ревью, их читает редактор и проверка типов, а установка сервиса не требует генератора. Отсюда два следствия.
Первое: правка руками в models.py живёт, пока кто-то не перегенерирует, — и обычно это происходит через месяц, в чужой ветке. Второе, которое и решает первое: сборка проверяет свежесть сгенерированного кода.
- run: uv run datamodel-codegen
- run: git diff --exit-code -- app/api/models.py
Изменили контракт и забыли перегенерировать — сборка красная. Поправили models.py руками — сборка красная. Правило «правки только в YAML» держится не на договорённости, а на двух строках конвейера.
Вторая ловушка: контракт и реализация разъезжаются
Главная беда contract-first не в том, что его трудно начать, а в том, что через полгода описание отличается от поведения. В Python закрыты только те места, которые проходят через модели.
Поля появляются мимо описания. Если у обработчика стоит response_model, лишние поля из словаря в ответ не попадут: Pydantic сериализует только поля модели. Но обработчик, вернувший JSONResponse со своим словарём, минует модель, и расхождение возвращается. Schemathesis лишнее поле заметит, только если у схемы стоит additionalProperties: false; иначе по правилам JSON Schema оно разрешено.
Коды ответов не совпадают. Обработчик FastAPI может вернуть любой код. Остаётся и единый обработчик ошибок: он отдаёт 409 и 422, о которых контракт может не знать. Ловится тем же тестом по схеме: код, которого нет в описании, — падение.
Примеры врут. Пример в описании собран руками год назад. Schemathesis с фазой examples прогоняет примеры из контракта как запросы, Spectral проверяет их по схеме.
Отсюда практическое правило: contract-first без проверки ответов по схеме превращается в code-first с лишним файлом. Описание должно быть проверяемым, иначе оно документация, а не контракт.
Чем проверяют контракт
spectral — линтер описания: у каждой операции есть operationId, описание и пример, имена в одном стиле, коды ответов перечислены, ошибки описаны единой схемой. Свои правила добавляют файлом настроек; запускают в сборке до генерации: npx @stoplight/spectral-cli lint api/openapi.yaml.
oasdiff — сравнение двух описаний. Различает совместимые изменения и ломающие: убрали поле из ответа, добавили обязательный параметр, сузили тип, убрали значение перечисления. Это один бинарник без зависимостей, в сборке сравнивают описание ветки с описанием основной ветки:
oasdiff breaking --fail-on ERR main/api/openapi.yaml api/openapi.yaml
Проверка ответов по схеме. Линт и сравнение проверяют описание, а не поведение. Schemathesis проверяет поведение: в pytest против ASGI-приложения или в сборке против поднятого сервиса командой schemathesis run api/openapi.yaml --url http://127.0.0.1:8000.
Моки для потребителя. prism mock api/openapi.yaml поднимает сервер, отвечающий примерами из описания, — фронтенд начинает работу, не дожидаясь реализации. Оговорка: мок отвечает примерами, поэтому качество примеров становится частью работы.
Кто владеет контрактом и где он лежит
В репозитории сервиса (обычный выбор). Описание меняется вместе с реализацией, ревью одно. Потребитель получает клиента как пакет: openapi-python-client генерирует готовый проект с pyproject.toml, его публикуют во внутренний индекс пакетов, и соседний сервис ставит orders-client==1.4.0 с закреплённой версией.
В отдельном репозитории схем (для организации с десятками сервисов). Все контракты в одном месте, общие правила линта, общие схемы ошибок. Минус — изменение контракта требует изменения в двух репозиториях.
Кто владеет: команда сервиса, а не команда потребителя. Потребитель участвует в обсуждении до принятия, и это оформляют как ревью изменения описания.
Чем платят за contract-first
Ревью описания. Каждое изменение API требует ревью ещё одного файла, и обсуждение формы занимает время до начала работы. Это и есть главная выгода (обсудить до кода) и главная цена (медленнее старт).
Конфликты при слиянии. Описание на несколько тысяч строк, в которое одновременно пишут пять человек. Лечится разбиением на файлы по тегам с $ref между ними — и генератор, и oasdiff умеют читать такие.
Генератор неудобен на краях. oneOf с discriminator превращается в Union с корневой моделью, загрузку файлов (multipart) и потоковые ответы описывают в контракте, а реализуют руками, минуя сгенерированные модели. Если таких краёв много, границу контракта проводят по ним явно: «эти три маршрута проверяются только Schemathesis».
Дисциплина против скорости. Прототип, который выкидывают через две недели, contract-first только замедляет. Контракт нужен там, где по ту сторону другая команда или внешний клиент; там, где обе стороны в одном репозитории и выкатываются вместе, он избыточен.
Компромисс: описание из кода, проверяемое как контракт
Самый частый путь в Python: сервис пишется на FastAPI code-first, а описание экспортируется в сборке и проверяется как контракт:
python -c 'import json; from app.main import app; print(json.dumps(app.openapi()))' > build/openapi.json
npx @stoplight/spectral-cli lint build/openapi.json
oasdiff breaking --fail-on ERR main/build/openapi.json build/openapi.json
Что это даёт: скорость code-first и главную гарантию contract-first (ломающее изменение не проходит незамеченным). Чего не даёт: обсуждения формы до реализации. Для внутренних сервисов этого обычно достаточно; для публичного API и контрактов между командами остаётся полноценный contract-first.
Контракт в работе: моки, версии, границы
Моки и параллельная работа. Главный практический выигрыш — мок прямо из контракта: Prism поднимает сервер по OpenAPI-файлу, и потребители начинают интеграцию, не дожидаясь готового бэкенда. Когда реальный сервис готов, переключаются на него — контракт тот же.
Версионирование и эволюция. Добавлять необязательные поля можно, удалять или переименовывать существующие — ломающее изменение, которое требует новой версии. Контракт — удобное место, где это видно: oasdiff показывает разницу двух YAML сразу. Подробный разбор — в статье про версионирование.
Когда API-first избыточен. Контракт нужно поддерживать, генерация добавляет шаг. Для одноразового прототипа или крошечного внутреннего маршрута это перебор. API-first окупается, когда API переживёт не один спринт, его потребляет больше одной команды или он публичный.
Глубже: проверка совместимости в сборкерасширенное
Обещание «контракт не сломается» ничего не стоит, пока его не проверяет сборка. Три инструмента разного уровня, и в Python-проекте все три живут в одном конвейере.
Сравнение двух версий. oasdiff breaking против главной ветки перечисляет ломающие изменения по правилам, которые статья про версионирование описывает словами. Сборка падает, если список не пуст и изменение не помечено как новая версия. Для gRPC то же делает buf breaking --against '.git#branch=main'.
Линт. Spectral с набором правил компании прогоняется на каждом изменении: каждая закрытая операция описывает 401 и 403, у каждого списка есть size с максимумом, у каждой ошибки тело application/problem+json. Правила — те самые соглашения из статей раздела, записанные машинно.
Контрактные тесты. Со стороны поставщика — Schemathesis в собственных тестах. Со стороны потребителя — Pact (pact-python): потребитель записывает, какие запросы делает и какие ответы ожидает, публикует контракт в брокер, а сборка поставщика проигрывает контракты всех потребителей против своего кода. Так поставщик узнаёт до выката, что поле, которое он «никому не нужным» удалил, читает мобильное приложение.
Порядок внедрения: сначала сравнение спецификаций — оно бесплатно и ловит самое дорогое; потом линт с пятью правилами, а не пятьюдесятью; контрактные тесты с потребителями — когда потребителей больше одного и они не в вашей команде.
Коротко
- API-first = сначала контракт (OpenAPI), потом код по нему; contract-first — источник правды YAML, code-first — код (FastAPI строит схему из типов сам).
datamodel-codegenделает из контракта модели Pydantic с ограничениями схемы (Field(ge=1),StrEnum), и проверка запроса достаётся бесплатно; настройки — вpyproject.toml.- Имя операции в FastAPI задают
name=иgenerate_unique_id_function, иначе клиент получит методget_order_orders__id__get. - В Python ничто не мешает обработчику вернуть код или поле вне контракта: роль компилятора играет Schemathesis, который сверяет ответы со схемой в pytest или в сборке.
connexionдержит контракт на сервере: проверяет запросы и ответы по спецификации, но даёт словари вместо моделей.- Сгенерированные модели коммитят, а свежесть проверяет сборка:
datamodel-codegenиgit diff --exit-code. - Расхождение контракта и кода ловят проверкой ответов по схеме; лишние поля заметны только при
additionalProperties: false; без проверки contract-first — code-first с лишним файлом. - Совместимость проверяет сборка:
oasdiff breakingпротив главной ветки, Spectral с правилами компании,pact-pythonс потребителями, когда их больше одного. oneOf,multipartи потоки у генератора бедные: описать в контракте, реализовать руками, проверять Schemathesis.- Контракт нужен там, где по ту сторону другая команда или внешний клиент; внутри одной команды хватает code-first с экспортом
app.openapi()иoasdiff.
Что почитать дальше
- OpenAPI: метаданные и типичные ошибки в REST на FastAPI — сам формат контракта:
operation_id, теги, параметры. - Версионирование REST API на FastAPI — как менять контракт, не ломая потребителей.
- URL и ресурсы REST на FastAPI — из чего складывается хорошо спроектированный контракт.
- Единый обработчик ошибок в FastAPI — куда подключить ошибки валидации, чтобы формат был один.