Опирается на правила: R-OAS-1..4, R-PRIN-X1, R-URL-X1..4, R-URL-X4, R-MTH-X1, R-NEST-X1..3, R-VER-X1..4, R-QRY-X1..5, R-RSP-X1..4, R-HDR-X1, R-ERR-X1..4, R-RATE-X1, R-DEP-X1, R-ALIAS-X1..2, R-ACT-X1..2, R-LOC-X1раздел OpenAPI-метаданные и антипаттерны.

Важно знать

  • Code-first: Pydantic-модели + декораторы маршрутов — источник OpenAPI; /openapi.json генерируется FastAPI автоматически. Перед мержем — review сгенерированной спеки.
  • operation_id — обязателен на каждом маршруте; FastAPI иначе генерирует неудобные авто-id вида post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post.
  • tags — один список на ресурс, множественное число с заглавной (Orders, Products); action-эндпоинты относятся к тегу родительского ресурса.
  • Параметры пути в FastAPI именуются уникально {order_id}, {item_id} — это требование инструмента; в дизайне URL — {id}.
  • summary и description задаются в декораторе; без них Swagger UI показывает голый путь.
  • Сводка антипаттернов из всех разделов гайда — единая таблица для ревью.
  • Дефолтный 422 FastAPI нарушает R-ERR-X3; переопредели RequestValidationError-handler на 400 VALIDATION_ERROR + violations.

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

operation_id

R-OAS-1: уникальный, camelCase, действие + ресурс — задаётся параметром operation_id в декораторе.

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.put("/orders/{order_id}", operation_id="updateOrder", summary="Заменить заказ")
async def update_order(order_id: UUID, ...) -> OrderResponse: ...

@router.patch("/orders/{order_id}", operation_id="patchOrder", summary="Обновить заказ")
async def patch_order(order_id: UUID, ...) -> OrderResponse: ...

@router.delete("/orders/{order_id}", operation_id="deleteOrder", status_code=204, summary="Удалить заказ")
async def delete_order(order_id: UUID) -> None: ...

@router.post("/orders/{order_id}/confirm", operation_id="confirmOrder", summary="Подтвердить заказ")
async def confirm_order(order_id: UUID) -> OrderResponse: ...

@router.post("/orders/search", operation_id="searchOrders", summary="Поиск заказов")
async def search_orders(body: OrderSearchRequest) -> PageResponse[OrderResponse]: ...

Конвенция имён:

  • get{Resource} — единичный ресурс (getOrder).
  • get{Resources} — коллекция (getOrders).
  • create{Resource} — POST-создание.
  • update{Resource} — PUT-замена.
  • patch{Resource} — PATCH-частичное обновление.
  • delete{Resource} — DELETE.
  • {глагол}{Resource} — action (confirmOrder, cancelOrder, shipProduct).
  • search{Resources}POST /resources/search.

Почему критично: SDK-генераторы (openapi-generator, openapi-python-client) используют operation_id как имя метода — orders_api.confirm_order(order_id). Без явного operation_id генератор строит имя из метода и пути, получается трудночитаемое post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post.

tags

R-OAS-2: один тег на ресурс.

# Глобальное описание тегов в app
app = FastAPI(
    openapi_tags=[
        {"name": "Orders", "description": "Управление заказами"},
        {"name": "Products", "description": "Каталог продуктов"},
        {"name": "Customers", "description": "Клиенты"},
    ]
)

# Тег на роутере — все маршруты наследуют
orders_router = APIRouter(prefix="/api/v1", tags=["Orders"])

@orders_router.post(
    "/orders/{order_id}/confirm",
    operation_id="confirmOrder",
    tags=["Orders"],      # action → тег родительского ресурса
    summary="Подтвердить заказ",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...

# Неверно: отдельный тег для action-эндпоинтов
@orders_router.post(
    "/orders/{order_id}/cancel",
    tags=["OrderActions"],    # нарушение R-OAS-2
    ...
)

Тег:

  • Имя — множественное число с заглавной (Orders, Products, Customers).
  • Action-эндпоинты относятся к тегу родительского ресурса. POST /orders/{id}/confirmOrders.
  • Без правильных тегов Swagger UI выдаёт flat-список всех эндпоинтов без структуры.

Параметры пути в OpenAPI

R-OAS-3: уникальные имена.

FastAPI именует path-параметры по имени аргумента функции. При вложенных ресурсах аргументы обязаны различаться:

@router.get(
    "/orders/{order_id}/items/{item_id}",
    operation_id="getOrderItem",
    summary="Позиция заказа",
)
async def get_order_item(
    order_id: UUID,    # не order_id + item_id, а уникальные
    item_id: UUID,
) -> OrderItemResponse: ...

Дизайн URL vs OpenAPI:

  • В дизайнеGET /orders/{id}/items/{id} (контекст ресурса устраняет неоднозначность по R-NEST-4).
  • В FastAPI{order_id}, {item_id} (требование инструмента — Swagger UI не работает с дублирующимися именами в одном пути).

Это не противоречие, а разрыв уровней: дизайн-нотация и реализация.

summary и description

R-OAS-4:

@router.post(
    "/orders/{order_id}/confirm",
    operation_id="confirmOrder",
    tags=["Orders"],
    summary="Подтвердить заказ",
    description="""
Переводит заказ из статуса `CREATED` в `CONFIRMED`.

Требования:
- заказ содержит хотя бы одну позицию;
- все позиции имеются на складе.

После подтверждения изменение состава заказа невозможно.
""",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...


@router.get(
    "/products/{product_id}",
    operation_id="getProduct",
    tags=["Products"],
    summary="Получить продукт",
    # description не нужен — логика очевидна
)
async def get_product(product_id: UUID) -> ProductResponse: ...
  • summary — до 80 символов. Именно он отображается в Swagger UI рядом с маршрутом.
  • description — Markdown; только если логика неочевидна. Пустая строка хуже отсутствия — создаёт шум.

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 OrderItemResponse(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
    )

    item_id: UUID
    product_id: UUID
    quantity: int = Field(ge=1)
    unit_price: int = Field(description="Цена в копейках")


class OrderResponse(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
    )

    order_id: UUID
    customer_id: UUID
    status: OrderStatus
    items: list[OrderItemResponse]
    created_at: datetime
    confirmed_at: datetime | None = None  # отсутствует в ответе, если не заполнено

Маршрут с response_model_exclude_none=True гарантирует выполнение R-RSP-X1:

@router.get(
    "/orders/{order_id}",
    operation_id="getOrder",
    tags=["Orders"],
    summary="Получить заказ",
    response_model_exclude_none=True,
)
async def get_order(order_id: UUID) -> OrderResponse: ...

Sber-пример — создание клиента с Location-заголовком (R-RSP-3):

from fastapi import Response

@router.post(
    "/customers",
    operation_id="createCustomer",
    tags=["Customers"],
    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

Переопределение обработчика валидации

FastAPI по умолчанию возвращает 422 Unprocessable Entity при ошибке Pydantic. Это нарушает R-ERR-X3. Переопредели handler:

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)R-URL-X4POST /api/v1/orders
CamelCase в пути (/orderItems)R-URL-X1/order-items
snake_case в пути (/order_items)R-URL-X1/order-items
Завершающий слеш (/orders/)R-URL-X2/orders
Расширение в пути (/orders.json)R-URL-X3/orders + Accept
ID в теле вместо путиR-NEST-X2PUT /orders/{order_id}
Глубина вложенности > 2R-NEST-X1фильтр на верхнем уровне
Mix единственного/множественногоR-RES-X2GET /orders/{id}/items
GET с побочным эффектомR-MTH-X1POST /orders/{id}/cancel

Версионирование

АнтипаттернПравилоЧто взамен
Версия в query (?version=2)R-VER-X2/api/v2/orders
Минорная версия (/api/v1.2/)R-VER-X1/api/v1/, /api/v2/
Дата в версии (/api/2026/)R-VER-X1v1, v2
Маршрут без /apiR-VER-X3/api/v1/...
Новая версия ради optional-поляR-VER-X4добавь в текущую версию

Query

АнтипаттернПравилоЧто взамен
Бизнес-логика в query (?action=cancel)R-QRY-X4POST /orders/{id}/cancel
CSV-массивы (?ids=1,2,3)R-QRY-X3повтор параметра ?ids=1&ids=2
page=0 (0-based) в публичном контрактеR-QRY-X2page=1 (1-based)
snake_case в параметрах (?customer_id=)R-QRY-X1Query(alias="customerId")
Парсинг cursor на клиентеR-QRY-X5opaque-токен

JSON и ответы

АнтипаттернПравилоЧто взамен
Envelope {"success": true, "data": ...}R-RSP-X4плоский ресурс
null в 2xx ответеR-RSP-X1response_model_exclude_none=True
"" для отсутствующего поляR-RSP-X2отсутствие поля
Optional ради null в ответеR-RSP-X3field: T \| None = None + exclude_none

Заголовки

АнтипаттернПравилоЧто взамен
Префикс X- в кастомных заголовкахR-HDR-X1доменный префикс (Shop-, Sber-)

Ошибки

АнтипаттернПравилоЧто взамен
Content-Type: application/json для ошибокR-ERR-X1application/problem+json
type: "about:blank"R-ERR-X2urn:problem:<service>:<code>
422 Unprocessable Entity (дефолт FastAPI)R-ERR-X3400 VALIDATION_ERROR + переопределённый handler
Stack traces, SQL в теле 500R-ERR-X4code + общий detail

Rate limiting и deprecation

АнтипаттернПравилоЧто взамен
429 без Retry-After и RateLimit-*R-RATE-X1все три заголовка
deprecated=True без Sunset-датыR-DEP-X1description с датой + заголовок Sunset

Alias и actions

АнтипаттернПравилоЧто взамен
me для эндпоинта, работающего только с текущим пользователемR-ALIAS-X1контекст из токена, singleton (/profile)
me без users/ префиксаR-ALIAS-X2/users/me
HATEOAS-ссылки в теле ответаR-PRIN-X1навигация в OpenAPI, Location при создании
Существительное в action (/confirmation)R-ACT-X1/confirm
Любой метод кроме POST для actionR-ACT-X2POST /orders/{id}/confirm

OpenAPI-метаданные

АнтипаттернПравилоЧто взамен
Авто-id FastAPI (длинные строки из пути)R-OAS-1явный operation_id="createOrder"
Отсутствие tagsR-OAS-2tags=["Orders"] в декораторе
Одинаковые имена параметров в одном путиR-OAS-3{order_id}, {item_id}
Отсутствие summaryR-OAS-4summary="Подтвердить заказ"

Локализация

АнтипаттернПравилоЧто взамен
Локализация code enum (ОШИБКА_ВАЛИДАЦИИ)R-LOC-X1VALIDATION_ERROR
Локализация JSON-ключей ({"статус": "..."} )R-LOC-X1camelCase английский
Локализация URIR-LOC-X1английский

Куда дальше

  • python/url-and-resources.md — R-NEST-4: {id} в дизайне vs {order_id} в FastAPI.
  • python/alias-and-actions.md — action operation_id, POST-метод.
  • python/versioning.md — APIRouter(prefix="/api/v1"), v1v2.
  • python/errors.md — переопределение 422→400, problem+json handler.
  • python/json-and-responses.md — exclude_none, R-RSP-X1..4.
  • python/query-params.md — camelCase-алиасы, повтор параметра, POST /search.
  • python/headers.md — Idempotency-Key, traceparent, кастомные без X-.
  • python/rate-limiting-files-deprecation.md — 429+Retry-After, Sunset.
  • python/batch-async-localization.md — 202 Accepted, POST /batch, Accept-Language.
  • Ошибки → error-handling (Python) — маппинг исключений в problem+json.