Опирается на правила:
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 показывает голый путь.- Сводка антипаттернов из всех разделов гайда — единая таблица для ревью.
- Дефолтный
422FastAPI нарушает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}/confirm→Orders. - Без правильных тегов 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-X4 | POST /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-X2 | PUT /orders/{order_id} |
| Глубина вложенности > 2 | R-NEST-X1 | фильтр на верхнем уровне |
| Mix единственного/множественного | R-RES-X2 | GET /orders/{id}/items |
| GET с побочным эффектом | R-MTH-X1 | POST /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-X1 | v1, v2 |
Маршрут без /api | R-VER-X3 | /api/v1/... |
| Новая версия ради optional-поля | R-VER-X4 | добавь в текущую версию |
Query
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Бизнес-логика в query (?action=cancel) | R-QRY-X4 | POST /orders/{id}/cancel |
CSV-массивы (?ids=1,2,3) | R-QRY-X3 | повтор параметра ?ids=1&ids=2 |
page=0 (0-based) в публичном контракте | R-QRY-X2 | page=1 (1-based) |
snake_case в параметрах (?customer_id=) | R-QRY-X1 | Query(alias="customerId") |
| Парсинг cursor на клиенте | R-QRY-X5 | opaque-токен |
JSON и ответы
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Envelope {"success": true, "data": ...} | R-RSP-X4 | плоский ресурс |
null в 2xx ответе | R-RSP-X1 | response_model_exclude_none=True |
"" для отсутствующего поля | R-RSP-X2 | отсутствие поля |
Optional ради null в ответе | R-RSP-X3 | field: T \| None = None + exclude_none |
Заголовки
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Префикс X- в кастомных заголовках | R-HDR-X1 | доменный префикс (Shop-, Sber-) |
Ошибки
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Content-Type: application/json для ошибок | R-ERR-X1 | application/problem+json |
type: "about:blank" | R-ERR-X2 | urn:problem:<service>:<code> |
422 Unprocessable Entity (дефолт FastAPI) | R-ERR-X3 | 400 VALIDATION_ERROR + переопределённый handler |
Stack traces, SQL в теле 500 | R-ERR-X4 | code + общий detail |
Rate limiting и deprecation
| Антипаттерн | Правило | Что взамен |
|---|---|---|
429 без Retry-After и RateLimit-* | R-RATE-X1 | все три заголовка |
deprecated=True без Sunset-даты | R-DEP-X1 | description с датой + заголовок 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 для action | R-ACT-X2 | POST /orders/{id}/confirm |
OpenAPI-метаданные
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Авто-id FastAPI (длинные строки из пути) | R-OAS-1 | явный operation_id="createOrder" |
Отсутствие tags | R-OAS-2 | tags=["Orders"] в декораторе |
| Одинаковые имена параметров в одном пути | R-OAS-3 | {order_id}, {item_id} |
Отсутствие summary | R-OAS-4 | summary="Подтвердить заказ" |
Локализация
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Локализация code enum (ОШИБКА_ВАЛИДАЦИИ) | R-LOC-X1 | VALIDATION_ERROR |
Локализация JSON-ключей ({"статус": "..."} ) | R-LOC-X1 | camelCase английский |
| Локализация URI | R-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"),v1→v2. - 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.