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

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

Обязательно

Alias и Action-эндпоинты: me, latest и доменные команды

Aliases · Commands · Routes

Одни адреса заменяют идентификатор смыслом, другие явно вызывают операцию, которую нельзя честно выразить сменой поля.

1. me разрешается из токена, не из пути

1 / 5

/users/me возвращает текущего пользователя без user_id. Клиент не выбирает чужой идентификатор, сервер получает его из подтверждённого контекста.

GET /users/meBearer tokenCurrentUsersub=42UserResponseid=42

2. latest и default имеют предметный смысл

2 / 5

/products/latest выбирает актуальную версию по правилу сервера, /settings/default - эталонные настройки. Это именованные ресурсы, а не случайные строки.

/products/latestorder_by(created_at DESC)limit 1/settings/defaultserver-defined resourcebaseline values

3. Статические alias идут перед {id}

3 / 5

Сначала /users/me и /products/latest, затем /users/{user_id} и /products/{product_id}. Иначе alias попадёт в динамический маршрут и получит ошибку типа.

1/users/mealias first2/users/{user_id}dynamic nextиначе "me" проверяется как user_id: int → 422

4. Action вызывает доменную команду

4 / 5

POST /orders/42/confirm выполняет confirm со своими инвариантами и побочными эффектами. PATCH status=CONFIRMED скрывал бы смысл и позволял обойти сценарий.

POST /orders/42/confirmIdempotency-KeyConfirmOrdercheck invariantsemit event200 / 409результат команды

5. PATCH подходит для простого поля

5 / 5

Изменение имени или адреса - обычное обновление ресурса. Оплатить, отменить, опубликовать и назначить - команды с доменными правилами, им нужны actions.

PATCH /users/42displayNameпростое обновлениеPOST /orders/42/cancelпроверка состояниякомпенсация + событие

CRUD отлично работает для простых операций: создать, прочитать, обновить, удалить. Но некоторые операции не укладываются в эту схему. Как получить профиль текущего пользователя без знания его ID? Как обозначить, что заказ подтверждён, а не просто поменялось поле status? Разберём два инструмента FastAPI для таких случаев.

Alias-сегменты — shortcuts в URL

Зачем нужен me

Чтобы получить профиль конкретного пользователя, обычно делают:

GET /users/{user_id}

Но пользователь не всегда знает свой ID. Он просто хочет посмотреть свой профиль. Для этого и нужен alias me:

GET /users/me

Сервер читает ID из токена авторизации и возвращает нужный профиль.

from fastapi import APIRouter, Depends
from .auth import get_current_user

router = APIRouter(prefix="/api/v1")

@router.get("/users/me")
async def get_current_user_profile(current_user = Depends(get_current_user)):
    ...

@router.get("/users/{user_id}")
async def get_user(user_id: str):
    ...

Важный нюанс с порядком маршрутов. FastAPI сопоставляет маршруты строго в порядке регистрации. Если /users/{user_id} зарегистрирован первым, то запрос GET /users/me попадёт туда, и user_id получит значение "me" — что, скорее всего, вызовет ошибку. Литеральные сегменты (/me, /latest) должны регистрироваться до параметрических (/{user_id}).

router = APIRouter()
router.include_router(users_me_router)   # литеральный — первым
router.include_router(users_id_router)  # параметрический — вторым

Когда me нужен, а когда нет

Простое правило: может ли администратор обратиться к этому же эндпоинту по чужому ID?

  • Да — нужны оба варианта (/users/me для обычного пользователя и /users/{user_id} для администратора).
  • Нет — эндпоинт и так работает только для текущего пользователя, me избыточен.

Частая ошибка — добавлять me туда, где он не нужен:

# Неверно: заказы и так берутся из токена
@router.get("/users/me/orders")

# Верно: /orders возвращает заказы текущего пользователя
@router.get("/orders")

Ещё одна ошибка — убирать users/ из пути:

# Неверно: me — это сокращение для users/{id}
@router.get("/me")

# Верно
@router.get("/users/me")

Alias для «последнего», «текущего», «следующего»

Иногда нужно получить не конкретный ресурс по ID, а «последний» или «текущий». Для этого используют временны́е alias:

@router.get("/products/{product_id}/versions/latest")
async def get_latest_version(product_id: str):
    ...

@router.get("/orders/current")
async def get_current_order(current_user = Depends(get_current_user)):
    ...

@router.get("/invoices/next")
async def get_next_invoice():
    ...

Правило порядка то же: /versions/latest регистрируется до /versions/{version_id}.

Alias по бизнес-признаку

Когда у пользователя может быть несколько объектов одного типа, но один из них «главный»:

@router.get("/payment-methods/default")
async def get_default_payment_method(current_user = Depends(get_current_user)):
    ...

@router.get("/addresses/primary")
async def get_primary_address(current_user = Depends(get_current_user)):
    ...

@router.get("/subscriptions/active")
async def get_active_subscription(current_user = Depends(get_current_user)):
    ...

Action-эндпоинты — доменные команды

Проблема с PATCH для сложных операций

CREATED CONFIRMED SHIPPED REFUNDED POST /confirm POST /ship POST /refund CANCELLED POST /cancel

Каждый переход между состояниями заказа это отдельная команда POST со своим путём, правами и проверками, а не PATCH со статусом в теле.

Есть соблазн обновлять поле status через PATCH:

PATCH /orders/{order_id}
{"status": "CONFIRMED"}

Но у этого подхода есть скрытая проблема: PATCH описывает изменение данных, а не бизнес-операцию. Подтверждение заказа — это не просто смена поля. Это может означать: списать деньги, отправить уведомление, поставить в очередь на сборку. Через PATCH такая семантика не видна ни из URL, ни из логов.

Action-эндпоинт делает намерение явным:

POST /orders/{order_id}/confirm

Из лога сразу понятно, что произошло.

Как оформить action-эндпоинт

from pydantic import BaseModel

class ShipOrderRequest(BaseModel):
    tracking_number: str
    carrier: str

class OrderResponse(BaseModel):
    order_id: str
    status: str

@router.post(
    "/orders/{order_id}/confirm",
    status_code=200,
    response_model=OrderResponse,
)
async def confirm_order(order_id: str) -> OrderResponse:
    ...

@router.post(
    "/orders/{order_id}/cancel",
    status_code=200,
    response_model=OrderResponse,
)
async def cancel_order(order_id: str) -> OrderResponse:
    ...

@router.post(
    "/orders/{order_id}/ship",
    status_code=200,
    response_model=OrderResponse,
)
async def ship_order(order_id: str, body: ShipOrderRequest) -> OrderResponse:
    ...

Несколько важных деталей:

  • Имя action — глагол в инфинитиве: confirm, cancel, ship. Не confirmation, не confirmed.
  • Метод всегда POST, даже если операция идемпотентна. Action — это команда, а не запрос данных.
  • status_code=200, не 201: action не создаёт новый ресурс, а возвращает обновлённое состояние существующего.
  • Тело запроса — Pydantic-модель, если нужны параметры. Если параметров нет — тело опционально.

Когда action, а когда PATCH

# Простое изменение поля без бизнес-правил — PATCH
@router.patch("/orders/{order_id}")
async def patch_order(order_id: str, body: PatchOrderRequest): ...

# Команда с доменной семантикой — action
@router.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: str): ...

Ориентир для выбора:

СитуацияЧто использовать
Меняем name, descriptionPATCH
Переводим в новый статус (конечный автомат)Action
Операция имеет доменное имяAction
Побочные эффекты: события, платежи, уведомленияAction
Простое поле без побочных эффектовPATCH

Частые ошибки с action-эндпоинтами

Использование существительного или причастия вместо глагола в инфинитиве:

# Неверно
@router.post("/orders/{order_id}/confirmation")
@router.post("/orders/{order_id}/confirmed")

# Верно
@router.post("/orders/{order_id}/confirm")

Использование PUT или PATCH для action:

# Неверно
@router.put("/orders/{order_id}/confirm")

# Верно
@router.post("/orders/{order_id}/confirm")
Дополнительно: при первом чтении можно пропустить

Глубже: права на действие: как это выглядит в кодерасширенное

Довод «orders:confirm отдельно от orders:write» стоит показать целиком, иначе он остаётся теорией. В FastAPI права на операцию объявляют через Security со scope:

oauth2 = OAuth2PasswordBearer(tokenUrl="auth/token", scopes={"orders:read": "читать заказы", "orders:confirm": "подтверждать", "orders:refund": "возвращать деньги"})

async def principal(scopes: SecurityScopes, token: Annotated[str, Depends(oauth2)]) -> Principal:
    claims = decode(token)
    missing = set(scopes.scopes) - set(claims["scope"].split())
    if missing:
        raise HTTPException(403, detail="forbidden")
    return Principal.from_claims(claims)

@router.post("/orders/{order_id}/confirm")
async def confirm(order_id: UUID, p: Annotated[Principal, Security(principal, scopes=["orders:confirm"])]): ...

@router.post("/orders/{order_id}/refund")
async def refund(order_id: UUID, p: Annotated[Principal, Security(principal, scopes=["orders:refund"])]): ...

Что здесь важно. Разные права на разные действия это и есть главная практическая выгода отдельных путей: оператор поддержки получает orders:confirm, возврат денег доступен только роли финансов. Один PATCH /orders/{id} со статусом в теле такой разницы не даёт: право одно на всё. И Security(..., scopes=[...]) попадает в OpenAPI как требование операции, так что сгенерированный клиент и Swagger UI знают, какой scope просить; об этом статья про контракт OpenAPI.

Глубже: массовые действия и частичный успехрасширенное

Самый спорный случай: POST /orders/confirm со списком идентификаторов. Спорный потому, что результат почти никогда не бывает целиком успешным. Три честных варианта, и выбирать надо осознанно.

Всё или ничего. Операция в одной транзакции: не прошёл один заказ, откатились все, ответ 409 с указанием виновника. Годится, когда элементов немного и они связаны по смыслу.

Независимо по элементам. Каждый заказ подтверждается отдельно, ответ 200 с результатом по каждому и сводкой «всего, успешно, с ошибкой». Клиент сам решает, что делать с неудачными. Формат ответа разобран в статье про массовые операции.

Асинхронно. Список в тысячи элементов принимают 202 Accepted с адресом задачи, а результат по элементам клиент забирает потом.

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

Коротко

  • me — alias для текущего пользователя вместо явного ID. Нужен только когда тот же эндпоинт может принять чужой ID (например, для администратора).
  • Порядок маршрутов в FastAPI критичен: литеральные (/me, /latest) регистрируются до параметрических (/{user_id}).
  • latest, current, default, primary — alias для singleton-выборки по временно́му или бизнес-признаку.
  • /me без users/ — ошибка: me — это сокращение для users/{id}, путь должен быть /users/me.
  • Action-эндпоинты — для доменных команд с семантикой: POST /orders/{id}/confirm, POST /orders/{id}/cancel.
  • Имя action — глагол в инфинитиве (confirm, не confirmation), метод — POST, статус — 200.
  • PATCH — для изменения данных, action — для доменных операций с побочными эффектами.
  • Отдельный путь на действие даёт отдельное право: Security(..., scopes=["orders:confirm"]) попадает и в проверку, и в OpenAPI; массовое действие честно либо атомарно, либо с результатом по элементам, либо 202.

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