Модуль 3 · OpenAPI

Справочник «Стандартизация HTTP API» · модуль 3 · OpenAPI

Глава 3.3

3.3

FastAPI
и OpenAPI

Описание учебного сервиса никто не писал руками: его собирает FastAPI из кода. В этой главе проследим путь от строки в Python до поля в документе и разберём, что делать, когда сгенерированное описание не совпадает с тем, что сервис отвечает на самом деле.

Категория
Описание контракта
Уровень
Средний
Связанные главы
3.2 Разделы документа · 2.4 Схемы · 4.1 Problem Details
Зачем это знать
Чтобы менять контракт осознанно: правкой кода, а не правкой файла описания

§1Откуда берётся описание

FastAPI читает три источника: параметры конструктора приложения, аргументы декораторов маршрутов и аннотации типов вместе с моделями Pydantic. Из них собирается документ OpenAPI, который отдаётся по адресу /openapi.json.

откуда что берётся

FastAPI(...)раздел info: название, версия, описание, контакты, лицензия; список servers; описания тегов
APIRouter(prefix, tags)общий префикс адресов и тег операций
@router.get(...)операция в paths: summary, description, operationId, ответы
аннотации функциипараметры: in: path, in: query, in: header, их типы и ограничения
модели Pydanticcomponents.schemas: поля, типы, обязательность, примеры
Depends(...)security у операции, если зависимость использует схему безопасности

Описание целиком получается из кода. Отсюда практическое следствие: чтобы изменить контракт, меняют код, а не файл в папке openapi/.

§2Метаданные приложения → info

Раздел info задаётся параметрами конструктора. В учебном проекте это файл app/main.py.

app/main.py
app = FastAPI(
    title="Service Desk API",
    version="1.1.0",
    summary="Учебный стандартизированный REST API",
    description=API_DESCRIPTION,
    contact={"name": "API Team", "email": "api-team@example.edu", …},
    license_info={"name": "MIT", "identifier": "MIT"},
    openapi_tags=[{"name": "Tickets", "description": "Обращения в службу поддержки…"}],
    servers=[{"url": "http://127.0.0.1:8000", "description": "Local development"}],
)

Всё это видно в шапке Swagger UI: название, версия, описание с разметкой Markdown, ссылка на контакт и лицензию, выпадающий список серверов.

Шапка Swagger UI: заголовок Service Desk API, версия, краткое описание, список пунктов из description, контакт и лицензия
Метаданные в интерфейсе. Каждая строка шапки соответствует параметру конструктора. Поле version — это версия контракта, а не версия FastAPI и не редакция OpenAPI.

§3Роутер и декоратор → операция

Адрес операции складывается из префикса роутера и пути в декораторе. Остальные поля операции — аргументы декоратора.

app/api/v1/endpoints/tickets.py
router = APIRouter(prefix="/tickets", tags=["Tickets"])

@router.get(
    "",
    response_model=TicketPage,
    summary="Получить список обращений",
    description="Возвращает страницу обращений. Размер страницы ограничен параметром limit.",
    operation_id="listTickets",
)
def list_tickets(
    status_filter: TicketStatus | None = Query(None, alias="status", description="Показать только обращения с этим статусом"),
    limit: int = Query(20, ge=1, le=100, description="Сколько обращений вернуть, от 1 до 100"),
) -> TicketPage:
    …
prefix + путьРоутер подключён с префиксом /api/v1, у роутера свой /tickets, у операции — пустой путь. Итог: /api/v1/tickets.адрес
response_modelСхема успешного ответа. Она же ограничивает, что функция вправе вернуть.ответ 200
operation_idИмя операции в описании. Без него FastAPI придумает своё, длинное и зависящее от имени функции.имя
Query(...)Место параметра, значение по умолчанию, ограничения и текст описания.параметры
Раскрытая операция в Swagger UI: summary рядом с адресом, описание, таблица параметров с ограничениями и блок ответов
Та же операция в интерфейсе. summary стоит рядом с адресом, description — под ним, ограничения ge и le превратились в подписи minimum и maximum.

§4Ответы: response_model и responses

Успешный ответ FastAPI описывает сам — по значению response_model. Об остальных ответах он не догадывается: то, что операция может вернуть 404 или 412, известно только из кода обработчика, а туда FastAPI не заглядывает. Поэтому ошибки перечисляют явно.

app/api/v1/endpoints/tickets.py · объявление ответов
@router.patch(
    "/{ticket_id}",
    response_model=Ticket,
    status_code=200,
    responses={
        401: {"description": "Токен не передан"},
        403: {"description": "Недостаточно прав"},
        404: {"description": "Обращение не найдено"},
        412: {"description": "Версия ресурса изменилась: If-Match не совпал"},
    },
    …
)
status_codeКод успешного ответа. По умолчанию 200; для создания задают 201, для удаления — 204.успех
response_modelСхема тела успешного ответа. Заодно отсекает лишние поля: что не описано в модели, наружу не уйдёт.тело
responsesВсе остальные коды с описаниями. Их набор — обещание клиенту: к этим случаям он готовится заранее.ошибки
Как это проверить

Пройдите по коду обработчика и выпишите каждый raise. Если код ошибки встречается в коде, но его нет в responses, — контракт неполный: клиент узнает об этом ответе в рабочем контуре, а не при чтении документации.

§5Схемы Pydantic → components

Каждая модель Pydantic, которая встречается в аннотациях, попадает в components.schemas. Имя схемы — имя класса, поля — поля модели, ограничения — параметры Field.

app/schemas.py
class TicketCreate(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        json_schema_extra={"examples": [{"title": "Не работает VPN", …}]},
    )
    title: str = Field(min_length=3, max_length=120, description="Короткая суть обращения")
    description: str = Field(default="", max_length=2000, description="Что делали и что увидели")
что получилось в описании
TicketCreate:
  type: object
  required: [title]
  additionalProperties: false
  properties:
    title:       { type: string, minLength: 3, maxLength: 120, description: … }
    description: { type: string, maxLength: 2000, default: '', description: … }
  examples:
    - { title: "Не работает VPN", description: "Ошибка подключения после обновления клиента" }

Обратите внимание на additionalProperties: false — так в описании выглядит настройка extra="forbid". Так в контракте выражен прямой запрет присылать поля, которых нет в схеме, — одна строка в коде превращается в проверяемое требование к запросу.

§6Разные модели для входа и выхода

Ресурс можно описать одной моделью и использовать её и в запросе, и в ответе. Делать так не следует: у этих моделей разный состав полей, потому что часть значений заполняет сервер.

TicketCreate — что присылает клиент
  • title — обязательно
  • description — необязательно
Ticket — что возвращает сервер
  • id — выдал сервер
  • status — начальное значение new
  • created_at, updated_at — время сервера
  • title, description — от клиента

Если объединить их в одну модель, клиент увидит в теле запроса поля id и created_at и решит, что их нужно присылать. Сервер эти значения проигнорирует, и клиент получит поведение, не совпадающее с описанием, — только из-за того, как составлена схема.

Отдельная модель нужна и для изменения. У TicketUpdate все поля необязательны: PATCH меняет только то, что прислали. Та же разница между PUT и PATCH, о которой шла речь в главе 2.2, выражена здесь схемами.

app/schemas.py · три модели одного ресурса
class TicketCreate(BaseModel):   # вход: создание
class TicketUpdate(BaseModel):   # вход: изменение, все поля Optional
class Ticket(BaseModel):         # выход: полное состояние ресурса
class TicketPage(BaseModel):     # выход: страница списка

Четыре класса вместо одного кажутся избыточными, пока не появляется вопрос «какие поля присылать при создании» — на него отвечает именно разделение схем.

§7Зависимости → security

Требование токена в FastAPI выражается зависимостью. Если зависимость использует схему безопасности, операция получает поле security автоматически.

app/security.py и вызов в операции
bearer_scheme = HTTPBearer(scheme_name="BearerAuth", auto_error=False)

def require_writer(credentials = Depends(bearer_scheme)) -> str:
    …

@router.post("", …)
def create_ticket(data: TicketCreate, token: str = Depends(write_rate_limit)):
    …
в описании операции
  security:
    - BearerAuth: []

Цепочка здесь длиннее, чем кажется: операция зависит от write_rate_limit, та — от require_writer, а та — от схемы bearer_scheme. FastAPI проходит цепочку целиком, поэтому security появляется даже при косвенной зависимости.

§8Когда описание нужно доработать

Автоматическая сборка описывает не всё. FastAPI не знает, что обработчики ошибок в проекте возвращают Problem Details, и по умолчанию описывает ответ 422 своей моделью с media type application/json. Получилось бы расхождение: контракт обещает одно, сервис отвечает другим.

Поэтому в учебном проекте описание дорабатывается после сборки — один раз, в одном месте:

app/main.py · доработка описания
def openapi_with_problem_details() -> dict:
    if app.openapi_schema:
        return app.openapi_schema
    schema = _generate_openapi()
    schemas = schema["components"]["schemas"]
    schemas.pop("HTTPValidationError", None)
    schemas["ProblemDetails"] = ProblemDetails.model_json_schema()
    for path_item in schema["paths"].values():
        for operation in path_item.values():
            for code, response in operation["responses"].items():
                response.setdefault("headers", {})["X-Request-Id"] = {…}
                if code.startswith(("4", "5")):
                    response["content"] = {"application/problem+json": {…}}
    return schema

Функция делает две вещи: переводит все ответы 4xx и 5xx на единый формат ошибок и добавляет к каждому ответу заголовок X-Request-Id, который в действительности добавляет middleware. После этого описание совпадает с поведением.

Как понять, что доработка нужна

Выполните операцию и сверьте ответ сервиса с её описанием. Если разошлись media type, заголовки или набор кодов, описание нужно поправить. Проверять это можно тестом, и в проекте такой тест есть: он требует, чтобы у каждой операции все ответы 4xx и 5xx были описаны как application/problem+json.

§9Проверка описания тестом

Описание — обычный словарь Python, который возвращает метод app.openapi(). Значит, его можно проверять так же, как любые данные.

tests/test_contract.py · фрагмент
def test_errors_use_problem_details():
    for path, method, operation in operations(app.openapi()):
        for code, response in operation["responses"].items():
            if code.startswith(("4", "5")):
                assert list(response["content"]) == ["application/problem+json"], f"{method.upper()} {path} {code}"

def test_committed_openapi_matches_code():
    committed = json.loads((ROOT / "openapi" / "openapi.json").read_text(encoding="utf-8"))
    assert committed == app.openapi(), "run: python scripts/export_openapi.py"

Второй тест — тот самый механизм против расхождения из главы 3.1, но со стороны тестов: он не даст объединить изменение, в котором код и выгруженный файл разошлись.

§10Частые ошибки

Править файл в openapi/ вручнуюПри следующей выгрузке правка исчезнет. Контракт меняют кодом.
Не задавать operation_idFastAPI сгенерирует имя из имени функции и пути; переименование функции изменит контракт для генераторов клиентов.
Одна модель для входа и выходаТогда клиент видит в теле запроса поля, которые заполняет сервер, и не понимает, что присылать.
Забыть про несоответствие описания поведениюЕсли обработчик ошибок возвращает свой формат, а описание — чужой, клиент напишет разбор по описанию и получит другое.

§11Проверьте себя

  1. Из каких трёх источников FastAPI собирает описание? Приведите пример поля для каждого.
  2. Как из prefix роутера и пути в декораторе получается адрес /api/v1/tickets?
  3. Что означает additionalProperties: false в схеме и какая строка кода его породила?
  4. Почему у операции createTicket появилось поле security, хотя схема безопасности не указана в декораторе?
  5. Зачем описание дорабатывается после автоматической сборки и что случилось бы без этого?
  6. Почему коды ошибок приходится перечислять вручную, а код успешного ответа — нет?
  7. Назовите две причины разделить модель запроса и модель ответа.
Код → описаниеFastAPI(...) → infoAPIRouter → tags, префикс@router.get(...) → operationPydantic → schemasDepends → security
Обязательно задаватьoperation_idsummary, descriptionresponse_modelresponses для ошибокstatus_code для 201 и 204
Модели ресурсаXCreate — входXUpdate — вход, всё OptionalX — выходXPage — страница списка
Правилоконтракт меняют кодомфайл в openapi/ — снимок
Проверкиpytest tests/test_contract.pyexport_openapi.py --check

Справочник «Стандартизация HTTP API» · модуль 3 · глава 3.3Макаров Максим Николаевич

Кадр