← назад к разделу

Когда вы пишете 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 — создание через POST
  • updateOrder — полная замена через PUT
  • patchOrder — частичное обновление через PATCH
  • deleteOrder — удаление
  • 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-алиасы, курсорная пагинация.