← назад к разделу

В 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 openapi.yaml договорились вместе мок-сервер для фронтенда бэкенд и фронтенд параллельно

При 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

  1. Пишем/правим OpenAPI-контракт — маршруты, схемы, ошибки, версии.
  2. Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
  3. Генерация — datamodel-codegen создаёт модели, openapi-python-client клиента; результат коммитится.
  4. Реализация — обработчики принимают и возвращают сгенерированные модели; сгенерированный файл не правится руками.
  5. Проверка в сборке — линт контракта, сравнение с предыдущей версией, свежесть моделей, прогон ответов по схеме.

Важное правило команды: правки контракта идут в 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.

Что почитать дальше