OpenAPI в FastAPI: настройка генерации, которая нравится фронтам
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, а именно файл, который получает фронт. Если ты в нём что-то с трудом понимаешь — значит, схему пора чистить.