Когда вы пишете API на FastAPI, документация появляется сама — по адресу /docs и /openapi.json. Но это не значит, что она будет хорошей. Разберём, как управлять тем, что генерируется, и каких ошибок стоит избегать.
Как FastAPI строит OpenAPI
В Java-мире OpenAPI обычно пишется вручную: сначала YAML-спека, из неё генерируется код. В FastAPI всё наоборот: код — источник спеки. Pydantic-модели и декораторы маршрутов автоматически превращаются в /openapi.json.
Это удобно: не надо синхронизировать YAML с кодом. Но значит, что если код написан небрежно — спека будет такой же.
operation_id: имя операции
Каждый маршрут в OpenAPI имеет идентификатор — operation_id. Если его не задать явно, FastAPI сгенерирует его сам из метода и пути:
post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post
Это проблема: SDK-генераторы (openapi-generator, openapi-python-client) используют operation_id как имя метода клиентского класса. Из такого идентификатора получится нечитаемый код.
Задавайте operation_id явно в формате действие + ресурс в camelCase:
router = APIRouter(prefix="/api/v1", tags=["Orders"])
@router.get("/orders", operation_id="getOrders", summary="Список заказов")
async def get_orders(...) -> PageResponse[OrderResponse]: ...
@router.post("/orders", operation_id="createOrder", status_code=201, summary="Создать заказ")
async def create_order(...) -> OrderResponse: ...
@router.get("/orders/{order_id}", operation_id="getOrder", summary="Получить заказ")
async def get_order(order_id: UUID) -> OrderResponse: ...
@router.post("/orders/{order_id}/confirm", operation_id="confirmOrder", summary="Подтвердить заказ")
async def confirm_order(order_id: UUID) -> OrderResponse: ...
Конвенция:
getOrder/getOrders— получить один или списокcreateOrder— создание через POSTupdateOrder— полная замена через PUTpatchOrder— частичное обновление через PATCHdeleteOrder— удалениеconfirmOrder,cancelOrder— действие над ресурсом
tags: группировка в Swagger UI
Без тегов Swagger UI показывает плоский список всех маршрутов без структуры. Теги группируют маршруты по ресурсам.
Правило: один тег на ресурс, множественное число с заглавной буквы — Orders, Products, Customers. Вложенные действия (confirm, cancel) относятся к тегу родительского ресурса, а не к отдельному тегу OrderActions.
app = FastAPI(
openapi_tags=[
{"name": "Orders", "description": "Управление заказами"},
{"name": "Products", "description": "Каталог продуктов"},
]
)
orders_router = APIRouter(prefix="/api/v1", tags=["Orders"])
# Правильно: action-эндпоинт под тегом родительского ресурса
@orders_router.post(
"/orders/{order_id}/confirm",
operation_id="confirmOrder",
tags=["Orders"],
summary="Подтвердить заказ",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...
Параметры пути при вложенных ресурсах
Когда у вас вложенный маршрут вроде /orders/{id}/items/{id}, FastAPI не может работать с одинаковыми именами параметров — Swagger UI тоже сломается. Именуйте параметры уникально:
@router.get(
"/orders/{order_id}/items/{item_id}",
operation_id="getOrderItem",
summary="Позиция заказа",
)
async def get_order_item(
order_id: UUID,
item_id: UUID,
) -> OrderItemResponse: ...
summary и description: что видит разработчик
summary — это короткая строка, которую Swagger UI показывает рядом с маршрутом. Без неё будет виден только путь. Держите до 80 символов.
description добавляйте только если логика нетривиальна — например, какие условия должны выполниться перед вызовом. Пустая строка хуже отсутствия.
@router.post(
"/orders/{order_id}/confirm",
operation_id="confirmOrder",
summary="Подтвердить заказ",
description="""
Переводит заказ из статуса `CREATED` в `CONFIRMED`.
Требования:
- заказ содержит хотя бы одну позицию;
- все позиции имеются на складе.
После подтверждения изменение состава заказа невозможно.
""",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...
Pydantic-модели как контракт
В code-first подходе OpenAPI-схемы генерируются из Pydantic-моделей. Правильно написанная модель — это уже правильная спека без дополнительного YAML.
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from uuid import UUID
from datetime import datetime
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
class OrderResponse(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
order_id: UUID
customer_id: UUID
status: OrderStatus
created_at: datetime
confirmed_at: datetime | None = None
alias_generator=to_camel означает, что в JSON поля будут в camelCase (orderId, customerId), хотя в Python-коде они snake_case. Это нужно, потому что REST-контракт ожидает camelCase.
Чтобы опциональные поля не появлялись в ответе как null, добавьте response_model_exclude_none=True на маршрут:
@router.get(
"/orders/{order_id}",
operation_id="getOrder",
summary="Получить заказ",
response_model_exclude_none=True,
)
async def get_order(order_id: UUID) -> OrderResponse: ...
При создании ресурса возвращайте заголовок Location с URL нового объекта:
from fastapi import Response
@router.post(
"/customers",
operation_id="createCustomer",
status_code=201,
summary="Зарегистрировать клиента",
response_model_exclude_none=True,
)
async def create_customer(body: CreateCustomerRequest, response: Response) -> CustomerResponse:
customer = await customer_service.create(body)
response.headers["Location"] = f"/api/v1/customers/{customer.customer_id}"
return customer
Ошибка валидации: 422 вместо 400
FastAPI по умолчанию возвращает 422 Unprocessable Entity при ошибке Pydantic. Это нестандартно: клиенты ожидают 400 Bad Request для ошибок входных данных. Переопределите обработчик:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import Response
import json
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> Response:
violations = [
{
"field": ".".join(str(loc) for loc in err["loc"] if loc != "body"),
"message": err["msg"],
"rejectedValue": err.get("input"),
}
for err in exc.errors()
]
body = {
"type": "urn:problem:orders:validation-error",
"title": "Validation Error",
"status": 400,
"code": "VALIDATION_ERROR",
"violations": violations,
}
return Response(
content=json.dumps(body, ensure_ascii=False),
status_code=400,
media_type="application/problem+json",
)
Частые ошибки проектирования
URL
Глагол в URL для CRUD — плохо: /createOrder. Хорошо: POST /api/v1/orders. Метод HTTP уже несёт смысл действия.
Неправильный регистр пути — camelCase (/orderItems) и snake_case (/order_items) в URL — ошибка. Используйте kebab-case: /order-items.
Завершающий слеш — /orders/ вместо /orders. Вызывает редиректы и путаницу.
Глубокая вложенность — /orders/{id}/items/{id}/variants/{id} тяжело читать и поддерживать. Глубже двух уровней — признак того, что нужен фильтр на верхнем уровне.
GET с побочным эффектом — GET /orders/{id}/cancel нарушает идемпотентность. Для действий используйте POST /orders/{id}/cancel.
Версионирование
Версия в query — ?version=2 вместо /api/v2/orders. Версия должна быть в пути.
Минорная версия — /api/v1.2/. Только мажорные версии: v1, v2.
Новая версия ради необязательного поля — если поле можно добавить, не сломав существующих клиентов, делайте это в текущей версии.
Query-параметры
CSV-массивы — ?ids=1,2,3 трудно парсить на некоторых платформах. Используйте повтор: ?ids=1&ids=2&ids=3.
snake_case в параметрах — ?customer_id= вместо ?customerId=. В FastAPI задайте алиас через Query(alias="customerId").
Нумерация с нуля — ?page=0 сбивает с толку пользователей API. Публичный контракт начинайте с page=1.
Ответы
Envelope — {"success": true, "data": {...}} — лишняя обёртка. Возвращайте ресурс напрямую.
null в ответе — поле с null занимает место и путает клиентов. Исключайте через response_model_exclude_none=True.
Пустая строка вместо отсутствия поля — "" — это не «поле отсутствует», это пустое значение. Разные семантики.
Ошибки
Неправильный Content-Type — для ошибок используйте application/problem+json, а не application/json.
Непрозрачный type — "type": "about:blank" бесполезен. Используйте "urn:problem:<service>:<code>".
Stack trace в теле 500 — никогда не отдавайте внутренние детали реализации. Только общий code и понятный detail.
OpenAPI-метаданные
Авто-id FastAPI — длинные строки вида post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post. Задавайте operation_id явно.
Отсутствие tags — Swagger UI покажет плоский список без группировки.
Отсутствие summary — в Swagger UI рядом с маршрутом будет только путь, без описания.
Кастомные заголовки
Не используйте префикс X- для кастомных заголовков (он устарел в RFC 6648). Вместо X-Request-Id используйте Shop-Request-Id или доменный префикс своего сервиса.
Локализация
Технические идентификаторы — коды ошибок, enum-значения, JSON-ключи, URL — должны быть на английском. VALIDATION_ERROR, а не ОШИБКА_ВАЛИДАЦИИ. Локализации подлежат только сообщения для пользователя в поле detail.
Коротко
- FastAPI генерирует OpenAPI автоматически из кода и Pydantic-моделей — управляйте качеством спеки через декораторы.
- Всегда задавайте
operation_idявно в camelCase (getOrders,confirmOrder) — иначе получите нечитаемые авто-имена. - Один тег на ресурс, множественное число с заглавной (
Orders). Action-эндпоинты — под тегом родительского ресурса. - При вложенных маршрутах именуйте параметры уникально:
{order_id},{item_id}. summaryобязателен,description— только если логика нетривиальна.- Переопределите обработчик
RequestValidationError: по умолчанию FastAPI возвращает422, нужно400. response_model_exclude_none=Trueубираетnull-поля из ответа.- Глагол в URL — ошибка; метод HTTP (GET/POST/PUT/DELETE) уже несёт смысл.
- Коды ошибок и enum-значения — только на английском.
Что почитать дальше
- URL и ресурсы — структура пути, вложенность, именование ресурсов.
- Ошибки RFC 9457 — problem+json, переопределение 422 → 400.
- JSON и формат ответов — exclude_none, camelCase, envelope-антипаттерн.
- Query-параметры и пагинация — camelCase-алиасы, курсорная пагинация.