lenec ru

← все посты

OpenAPI в FastAPI: настройка генерации, которая нравится фронтам

19K

OpenAPI-схема в FastAPI генерируется бесплатно. Открыл /docs — и вот тебе Swagger UI. Замечательно, пока ты пишешь сам бэкенд. Но потом за неё берутся фронтендеры с генератором клиентов, и начинается: имена методов кривые, типы any, ошибки описаны двусмысленно, общий error везде один и тот же. Ты говоришь «генерация лажает» — а на самом деле лажает твой OpenAPI.

Я расскажу, как мы у себя настроили генерацию схемы так, чтобы фронту было удобно. Это не про «сделать красивее в Swagger», это про реальные настройки, которые улучшают сгенерированный TypeScript-клиент и убирают трения между командами.

Базовая идея: схема — публичный контракт

Сначала простая мысль, которую полезно проговорить: OpenAPI-схема — это API-контракт, не просто документация. Фронт по ней строит клиент, мобилка по ней строит клиент, тестовая команда по ней пишет проверки. Любая муть в схеме автоматически становится мутью на трёх уровнях.

Из этого следует: схему надо проектировать, а не оставлять на «как сгенерится — так сгенерится».

Operation ID: основа красивого клиента

Дефолтное поведение FastAPI: operation_id = имя_функции_слово_путь_слова_метод. На выходе получаешь read_user_users__user_id__get. Понятно, что это, понятно, кому это понятно — никому.

В сгенерированном TypeScript-клиенте такие имена становятся методами вроде readUserUsersUserIdGet(). На код-ревью поверишь, что фронт сам так пишет.

Решение — задавать operation_id руками или автогенерировать по правилу:

def custom_generate_unique_id(route: APIRoute) -> str:
    if route.tags:
        return f"{route.tags[0].lower()}_{route.name}"
    return route.name

app = FastAPI(generate_unique_id_function=custom_generate_unique_id)

Теперь endpoint read_user с тегом users станет users_read_user. Сгенерированный метод — usersReadUser(). Уже читаемо.

Если хочется ещё чище, в декораторе можно явно указать:

@app.get("/users/{user_id}", operation_id="getUser")
async def read_user(user_id: int) -> UserOut:
    ...

Но мы предпочитаем правило по тегам — меньше ручной работы.

Схемы ошибок: универсальный формат

Главное, что бесит фронт: на разных эндпоинтах разные форматы ошибок. На одном {"error": "..."}, на другом {"detail": "..."}, на третьем строка вместо объекта. В сгенерированном клиенте тип ответа — мешанина из any.

Лечится единой моделью ошибок и явным указанием responses на эндпоинте.

from pydantic import BaseModel
from typing import Literal

class ApiError(BaseModel):
    code: Literal["validation_error", "not_found", "forbidden", "internal"]
    message: str
    field: str | None = None

ERROR_RESPONSES = {
    400: {"model": ApiError, "description": "Validation error"},
    404: {"model": ApiError, "description": "Not found"},
    403: {"model": ApiError, "description": "Forbidden"},
}

@app.get("/users/{user_id}", responses=ERROR_RESPONSES, response_model=UserOut)
async def read_user(user_id: int) -> UserOut:
    ...

Теперь в схеме у каждого статуса есть конкретный тип, и фронт получит дискриминированный union.

Можно вынести стандартные responses в декоратор-фабрику:

def errors(*codes: int):
    return {code: ERROR_RESPONSES[code] for code in codes}

@app.get("/users/{user_id}", responses=errors(404), response_model=UserOut)
async def read_user(user_id: int) -> UserOut: ...

На каждом эндпоинте указываешь только релевантные ошибки.

Имена моделей в схеме

Pydantic-модели попадают в components/schemas с именем класса. Если у тебя в проекте 10 классов с именем UserOut в разных модулях (потому что они в разных доменах), Pydantic это разрулит, добавив суффикс. Но имена будут вида UserOut__app__users__schemas. В клиенте — кошмар.

Решения:

  • Делать имена уникальными: UserListOut, UserDetailOut, UserPublicOut. Вообще полезно — лучше видишь, какая модель где используется.
  • Использовать generate_unique_id_function и кастомизировать title в моделях.

Мы пошли первым путём. Мелкий рефакторинг, зато читаемость на стороне фронта улучшилась радикально.

Документация полей

Каждое поле в pydantic-модели может иметь description:

from pydantic import BaseModel, Field

class UserOut(BaseModel):
    id: int = Field(description="Идентификатор пользователя")
    email: str = Field(description="Основной email, нормализован к нижнему регистру")
    created_at: datetime = Field(description="UTC timestamp создания")

В Swagger UI это превращается в подсказки, в сгенерированном TypeScript — в JSDoc-комментарии. Полезно фронту, полезно при дебаге, полезно через полгода, когда забыл, что значит created_at.

Для Optional полей не забывай default=None, иначе в схеме поле будет требуемым.

Примеры запросов и ответов

Хороший пример в схеме — это бесплатная документация. Поддерживает Swagger, Redoc, генераторы клиентов.

class UserCreate(BaseModel):
    email: str
    name: str

    model_config = {
        "json_schema_extra": {
            "examples": [
                {
                    "email": "alice@example.com",
                    "name": "Alice"
                }
            ]
        }
    }

Для эндпоинта пример ответа задаётся через responses:

@app.post(
    "/users",
    response_model=UserOut,
    responses={
        201: {
            "content": {"application/json": {"example": {"id": 1, "email": "alice@example.com"}}}
        }
    },
)
async def create_user(payload: UserCreate) -> UserOut: ...

Не нужно делать примеры каждого поля — но дать один-два полноценных примера на ключевые модели стоит.

Скрытие внутренних эндпоинтов

Не всё, что есть в API, должно быть в публичной схеме. Метрики, healthcheck, debug-эндпоинты — лучше скрыть:

@app.get("/internal/metrics", include_in_schema=False)
async def metrics(): ...

Так фронт не увидит лишних методов в клиенте, и тебе не надо думать о их совместимости.

Группировка по тегам

Тэги — это способ сказать «эти эндпоинты логически вместе». В сгенерированных клиентах часто из тэгов делают неймспейсы.

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/")
async def list_users(): ...

@router.post("/")
async def create_user(): ...

Имя тэга — короткое, читаемое, в нижнем регистре. Не «Users API endpoints v2» — а просто «users».

Версионирование схемы

Если у тебя несколько версий API, сделай разные FastAPI-приложения и смонтируй их:

v1 = FastAPI(title="API v1", version="1.0.0")
v2 = FastAPI(title="API v2", version="2.0.0")

main = FastAPI()
main.mount("/v1", v1)
main.mount("/v2", v2)

Каждое приложение даст свою схему по адресу /v1/openapi.json и /v2/openapi.json. Фронт может генерить два отдельных клиента и не путаться.

Кастомизация openapi.json

Иногда нужно тонко докрутить схему: добавить общую секцию security, поменять server URL под окружение, добавить теги для Redoc.

from fastapi.openapi.utils import get_openapi

def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    schema = get_openapi(title="My API", version="1.0", routes=app.routes)
    schema["servers"] = [{"url": "https://api.example.com"}]
    app.openapi_schema = schema
    return schema

app.openapi = custom_openapi

Базовая схема при этом продолжает строиться FastAPI, ты только надстраиваешь поверх. Кэширование в app.openapi_schema важно — иначе на каждый запрос строится заново.

CI: проверяем, что схема не сломалась

Сгенерированный клиент — часть CI/CD фронта. Если бэк меняет endpoint и забывает обновить версию — фронт ломается на следующем деплое.

У нас в CI есть простая проверка: на каждый PR с изменением API мы вытаскиваем openapi.json, сравниваем с baseline, и если есть breaking changes (удалён endpoint, поменялся required/optional, поменялся тип) — собираем алерт. Инструменты типа openapi-diff делают это из коробки.

Что из этого работает в реальности

Из десятка настроек реально критичные две: кастомный operation_id и единый формат ошибок. Они дают самый заметный эффект на стороне фронта. Остальные — мелочи, которые делают схему чище и помогают через год.

Самое полезное правило: время от времени сам открывай сгенерированный клиент. Не Swagger UI, а именно файл, который получает фронт. Если ты в нём что-то с трудом понимаешь — значит, схему пора чистить.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.