Проверка 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 заданий с автоматической проверкой.
