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

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

OpenAPI в FastAPI и ошибки проектирования REST

operationId · Schemas · Swagger

FastAPI генерирует спецификацию из кода, но качество operationId, схем и ответов остаётся ответственностью разработчика.

1. Код становится источником OpenAPI

1 / 5

Декораторы описывают операции, Pydantic - схемы, FastAPI собирает /openapi.json. Из него появляются Swagger UI, SDK, моки и проверки совместимости.

FastAPIroutes + modelsopenapi.jsonpaths + componentsSwagger UISDK + mockoasdiff

2. operationId стабилен и уникален

2 / 5

Короткое getOrder удобнее сгенерированного имени функции и пути. Клиентский генератор создаёт из него методы, поэтому смена id меняет публичный SDK.

GET /orders/{id}operation_id="getOrder"OpenAPIunique operationIdclient.getOrderstable SDK

3. tags и summary делают документацию читаемой

3 / 5

Операции группируются по предметным областям, summary отвечает на вопрос «что делает», description объясняет правила и ограничения.

tag: orderscreate · confirm · gettag: cataloglist · search · updatesummary: "Confirm an order"description: state rules + errors

4. Модели ответов описывают реальный контракт

4 / 5

response_model фильтрует лишнее и создаёт схему. Отдельные Create и Response модели не позволяют клиенту записать серверные поля или получить внутренние.

CreateOrderRequestcustomerId · itemsrequired inputOrderResponseid · status · totalpublic output

5. Антипаттерны попадают в спецификацию

5 / 5

Глаголы в путях, один JSON на все операции, 200 на ошибку и 422 на весь неверный ввод становятся контрактом. Их исправляют до публикации клиентам.

Плохой контрактPOST /getOrder200 + error messageодна модель на всёХороший контрактGET /orders/{id}404 + problem+jsonточные schemas

Когда вы пишете API на FastAPI, документация появляется сама — по адресу /docs и /openapi.json. Но это не значит, что она будет хорошей. Разберём, как управлять тем, что генерируется, и каких ошибок стоит избегать.

Как FastAPI строит OpenAPI

В Java-мире OpenAPI обычно пишется вручную: сначала YAML-спека, из неё генерируется код. В FastAPI всё наоборот: код — источник спеки. Pydantic-модели и декораторы маршрутов автоматически превращаются в /openapi.json.

декораторы и Pydantic app.openapi() из кода Swagger UI /docs клиентский SDK openapi-generator проверка совместимости oasdiff breaking в CI мок-сервер для фронтенда

Спецификация собирается из декораторов и моделей, и её читают четыре потребителя; поэтому operation_id, tags и summary задают явно.

Это удобно: не надо синхронизировать 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.

Дополнительно: при первом чтении можно пропустить

Глубже: безопасность в контракте: схемы, области доступа и security на операциирасширенное

У безопасности в контракте три уровня, и пропуск любого из них означает, что сгенерированный клиент не знает, как представиться, а Swagger UI не даёт нажать «попробовать».

Первый уровень это схемы в components.securitySchemes. FastAPI заводит их сам по классам из fastapi.security, которые встречаются в зависимостях: OAuth2PasswordBearer(tokenUrl=..., scopes={...}) даёт схему oauth2 с перечнем областей доступа, HTTPBearer даёт http с bearer. Руками в спеку их не дописывают.

Второй уровень это требование на операции. Оно появляется у эндпоинта, в сигнатуре которого есть зависимость с такой схемой, а Security(principal, scopes=["orders:confirm"]) записывает в security операции ещё и нужные области: в app.openapi() у POST /orders/{order_id}/confirm будет [{"OAuth2PasswordBearer": ["orders:confirm"]}]. Операция без зависимости безопасности остаётся без security, и это правильно для /health/*, но ошибка для всего остального.

Третий уровень это проверка, что второй выполнен везде. Пишут один тест: пройти по app.openapi()["paths"] и упасть, если у операции вне белого списка нет security. Он ловит эндпоинт, где забыли Depends(principal), раньше, чем его найдёт сканер или злоумышленник.

Что не делают: не выключают openapi_url в проде вместо настройки безопасности. Спрятанная документация не защищает эндпоинт, а клиентам, которые генерируют код по спеке, её отдают отдельно из сборки.

Коротко

  • 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-значения — только на английском.
  • Безопасность — часть контракта: OAuth2PasswordBearer и HTTPBearer попадают в securitySchemes, Security(..., scopes=[...]) — в security операции; тест по app.openapi() ловит эндпоинт без защиты.

Что почитать дальше