Проверка OpenAPI в FastAPI: как поймать новое обязательное поле

Сервис доставки принимает POST /shipments с номером заказа. Команда добавляет необязательный по смыслу код пункта выдачи и объявляет его как pickup_code: str | None. Ручная проверка с новым клиентом проходит. Мобильное приложение предыдущей версии продолжает отправлять только номер заказа и после выпуска получает 422.

Тип str | None описывает значения, которые можно передать. При отсутствии значения по умолчанию ключ остаётся обязательным. Для сервера {"pickup_code": null} и JSON без ключа означают две разные вещи. Если прежний клиент вправе пропустить поле, задайте pickup_code: str | None = None. Тогда ключ можно пропустить, а в обработчик попадёт значение по умолчанию.

Сравнивайте схему входа на конкретном маршруте

FastAPI публикует описание запроса в OpenAPI. В схеме JSON список required определяет обязательное присутствие ключей, а описание самого свойства определяет допустимые значения. При сравнении версий возьмите схему из requestBody маршрута. Для модели ответа FastAPI может сгенерировать отдельную схему: если сервер всегда добавляет поле в выходной JSON, оно может быть обязательным в ответе даже при необязательном входе.

Ниже три версии маленького маршрута. Для первого шага сохраняем схему действующего приложения. Затем сравниваем её с изменением и получаем имя нового обязательного поля. Тест после сравнения воспроизводит ошибку на прежнем теле запроса и проверяет исправленный вариант. Функция разбирает локальную ссылку на модель и проверяет верхнеуровневый объект.

Сохраните пример в файл demo.py, установите зависимости командой python -m pip install fastapi httpx и запустите python demo.py.

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel


class ShipmentV1(BaseModel):
    order_id: int


class ShipmentBroken(BaseModel):
    order_id: int
    pickup_code: str | None


class ShipmentCompatible(BaseModel):
    order_id: int
    pickup_code: str | None = None


def build_app(model: type[BaseModel]) -> FastAPI:
    app = FastAPI()

    @app.post("/shipments")
    def create_shipment(request: model):
        return {"order_id": request.order_id}

    return app


def request_required(document: dict, path: str, method: str) -> set[str]:
    body = document["paths"][path][method]["requestBody"]
    schema = body["content"]["application/json"]["schema"]
    if "$ref" in schema:
        name = schema["$ref"].removeprefix("#/components/schemas/")
        schema = document["components"]["schemas"][name]
    if schema.get("type") != "object" or "properties" not in schema:
        raise ValueError("Expected a single JSON object with properties")
    return set(schema.get("required", []))


old = build_app(ShipmentV1)
broken = build_app(ShipmentBroken)
compatible = build_app(ShipmentCompatible)
path = "/shipments"
baseline = request_required(old.openapi(), path, "post")
added = request_required(broken.openapi(), path, "post") - baseline
assert added == {"pickup_code"}
assert not request_required(compatible.openapi(), path, "post") - baseline

with TestClient(broken) as client:
    rejected = client.post(path, json={"order_id": 41})
    assert rejected.status_code == 422
    assert rejected.json()["detail"][0]["loc"] == ["body", "pickup_code"]
    assert client.post(path, json={"order_id": 41, "pickup_code": None}).status_code == 200
with TestClient(compatible) as client:
    assert client.post(path, json={"order_id": 41}).json() == {"order_id": 41}
print("New required properties:", sorted(added))

Проверка находит pickup_code, хотя поле принимает null. Затем живой запрос показывает, какое тело продолжает работать. В отчёте CI полезно печатать HTTP-метод, путь и имя обязательного поля. По этой тройке разработчик найдёт изменившийся контракт даже при одинаковых названиях схем у разных версий.

Возьмите за основу опубликованную версию

Храните эталонный openapi.json последнего выпуска. В CI возьмите его как вход и сгенерируйте документ из приложения текущей ветки. Если оба файла обновляются одним изменением до проверки, добавленное поле окажется в обоих списках и сравнение ничего не заметит. Для маршрутов, где обязательный атрибут действительно нужен, можно сначала выпускать поддержку поля по умолчанию. Когда потребители начнут присылать данные, новый обязательный вариант вынесите в новую версию контракта.

Для полей во вложенных объектах, схем с oneOf и изменений типов добавьте отдельные проверки или используйте инструмент сравнения OpenAPI, который умеет эти конструкции. Сохраняйте также несколько реальных тел запросов предыдущей версии. Модель может остаться совместимой на бумаге, а дополнительный валидатор или обработчик уже начнёт отклонять прежний сценарий. Тесты старых тел покажут такой разрыв.

На бесплатном курсе Latorn по FastAPI вы соберёте приложение, определите модели Pydantic и проверите рабочие и ошибочные запросы через pytest и TestClient. Открыть программу курса FastAPI и урок про тесты маршрутов.

Продолжить на практике

  • FastAPI для начинающих: API с базой данных и тестами ↗

    Бесплатный курс backend-разработки после основ Python. Соберёте свой API на FastAPI: база данных, регистрация, JWT-авторизация и тесты pytest. Один сквозной проект, 34 коротких урока и 140 заданий с автоматической проверкой.