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

API: REST И КОНТРАКТЫ Alias и Action

Обязательно

Alias и Action-эндпоинты в Go REST

Go · chi

Как адресовать текущего пользователя и канонический вариант ресурса и когда доменной команде нужен POST вместо PATCH.

1. /me разрешается из авторизации, не из ID

1 / 5

/users/me обозначает текущего пользователя. Handler получает userID из проверенного контекста, а не принимает его как свободный path-параметр.

  • Alias удобен клиенту и не дублирует идентичность.
  • Правила доступа сохраняются.
  • /users/{id} остаётся для явного ресурса.
GET /users/meAuth contextuserID = 42ID берётся из проверенного principal

2. Latest и default ведут к каноническому ресурсу

2 / 5

Alias полезен, если выбор имеет доменный смысл: latest release, default config, primary address. Он не должен скрывать случайные вычисления.

  • Можно вернуть ресурс или redirect на канонический URL.
  • Правило выбора детерминировано.
  • Кэш учитывает изменение канонической цели.
/releases/latestResolve/releases/v2.4Alias имеет стабильное правило выбора

3. Action выражает доменную команду

3 / 5

POST /orders/42:cancel или /orders/42/cancel вызывает переход состояния с правилами и побочными эффектами. Это не простая замена поля status.

  • Тело содержит параметры команды и idempotency.
  • Ответ описывает новый ресурс или результат.
  • Неподходящее состояние приводит к 409.
POST /orders/42/cancelCancelOrderCANCELLEDКоманда проверяет инварианты и фиксирует событие

4. PATCH подходит представлению, не любой команде

4 / 5

PATCH {"status":"CANCELLED"} позволяет обойти проверки, если клиент может писать поле напрямую. Доменный метод ограничивает допустимые переходы.

  • Нельзя отменить уже доставленный заказ.
  • Причина команды валидируется отдельно.
  • Идемпотентность повторяет результат, не побочные эффекты.
PATCH statusлюбой переход?нет доменных правилPOST cancelallowed transitionssingle transactionИмя операции отражает модель

5. Стиль команд выбирается один раз

5 / 5

/orders/42/cancel, /orders/42:cancel и /order-cancellations имеют разные компромиссы. Команда не должна превращать весь API в RPC.

  • Сначала проверить обычный ресурс и переход состояния.
  • Выбранный шаблон применять последовательно.
  • Ошибки и идемпотентность остаются общими.
POST /orders/42/cancelPOST /orders/42/confirmProblemDetails + Idempotency-Key + 409

Когда проектируешь REST API, быстро возникает два вопроса: как адресовать «себя» без хардкода своего ID, и как выражать доменные команды вроде «подтвердить заказ» или «заблокировать пользователя», когда обычного PATCH недостаточно. Именно для этого существуют alias-сегменты и action-эндпоинты.

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

Представь: у тебя есть эндпоинт GET /users/{id}, которым администратор смотрит любого пользователя. Обычный пользователь тоже хочет видеть свой профиль — но зачем ему знать собственный ID? Он просто хочет «посмотреть себя».

Alias me решает это: GET /users/me читается как «получи меня», а сервер сам достаёт ID из токена.

r.Route("/api/v1/users", func(r chi.Router) {
    r.Get("/{id}", getUser)    // GET /users/42 — администратор смотрит любого
    r.Get("/me", getMyUser)    // GET /users/me — пользователь смотрит себя
    r.Put("/{id}", updateUser)
    r.Put("/me", updateMyUser)
})

Обработчик getMyUser не принимает ID из URL — он берёт его из контекста запроса (JWT или сессия):

func getMyUser(w http.ResponseWriter, r *http.Request) {
    userID := mustUserID(r.Context())
    user, err := svc.GetUser(r.Context(), userID)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toUserResponse(user))
}

А getUser принимает {id} и проверяет права — администратор может смотреть любого:

func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    if !canViewUser(r.Context(), id) {
        httperr.Write(w, r, apperr.NewForbidden("access denied"))
        return
    }
    user, err := svc.GetUser(r.Context(), id)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toUserResponse(user))
}

Ключевое правило: me нужен только тогда, когда у того же эндпоинта есть версия с {id} для администратора. Проверочный вопрос: «Может ли супер-администратор обратиться к этому ресурсу по чужому ID?» Если да — me оправдан.

Когда me не нужен

Если эндпоинт изначально работает только с ресурсами вызывающего — не нужно никакого me. Просто /profile или /settings:

r.Get("/profile", getMyProfile)   // всегда про текущего пользователя из токена
r.Put("/settings", updateMySettings)

Добавлять /me к таким эндпоинтам — лишний шум. И никогда не делай /api/v1/me без префикса ресурса: правильно /api/v1/users/me, а не /api/v1/me.

Временные и порядковые alias

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

r.Get("/deployments/latest", getLatestDeployment)
r.Get("/subscriptions/current", getCurrentSubscription)
r.Get("/products/{id}/latest-review", getLatestReview)
r.Get("/invoices/next", getNextInvoice)

Обработчик прост — логика выборки «последнего» спрятана в сервисе:

func getLatestDeployment(w http.ResponseWriter, r *http.Request) {
    d, err := svc.GetLatestDeployment(r.Context())
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toDeploymentResponse(d))
}

Такие alias работают для singleton-выборки — когда возвращается ровно один объект по определённому критерию. Использовать их как фильтр для коллекций не стоит.

Логические alias

Похожая идея, но для бизнес-понятий: «способ оплаты по умолчанию», «основной адрес», «активный тариф». Пользователь знает, что у него есть «главный» объект, но не знает его ID:

r.Get("/payment-methods/default", getDefaultPaymentMethod)
r.Get("/addresses/primary", getPrimaryAddress)
r.Get("/plans/active", getActivePlan)

Например, карта платежей отдаёт метод по умолчанию:

func getDefaultPaymentMethod(w http.ResponseWriter, r *http.Request) {
    customerID := mustUserID(r.Context())
    pm, err := svc.GetDefaultPaymentMethod(r.Context(), customerID)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toPaymentMethodResponse(pm))
}

Это удобно и для клиента (не нужно сначала запрашивать список и искать «дефолтный»), и для API (URL читается как предложение на человеческом языке).

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

Не все операции выражаются через CRUD. «Подтвердить заказ», «отменить», «отправить», «заблокировать пользователя» — это доменные команды, которые меняют состояние и часто запускают побочные эффекты (события, уведомления, переходы машины состояний).

Пытаться передать такую команду через PATCH /orders/{id} с полем status: CANCELLED — неудачная идея. Тело теряет семантику команды, серверу сложнее валидировать переходы, а в документации непонятно, какие значения допустимы.

Вместо этого используют отдельные action-эндпоинты: POST /{resource}/{id}/{действие}.

Регистрация в chi

r.Route("/api/v1/orders", func(r chi.Router) {
    r.Get("/", listOrders)
    r.Post("/", createOrder)
    r.Route("/{id}", func(r chi.Router) {
        r.Get("/", getOrder)
        r.Patch("/", patchOrder)
        r.Post("/confirm", confirmOrder)
        r.Post("/cancel", cancelOrder)
        r.Post("/ship", shipOrder)
        r.Post("/refund", refundOrder)
    })
})

r.Route("/api/v1/customers", func(r chi.Router) {
    r.Route("/{id}", func(r chi.Router) {
        r.Post("/verify", verifyCustomer)
        r.Post("/block", blockCustomer)
    })
})

Действие в URL — всегда глагол в инфинитиве (confirm, ship, refund). Не существительное (confirmation, shipment) и не прошедшее время (confirmed, shipped).

Метод всегда POST — даже если операция идемпотентна. Это соглашение, которое упрощает жизнь клиентам и промежуточным слоям: POST на action-URL всегда означает «выполни команду».

Обработчик с параметрами

Если команде нужны дополнительные данные — они передаются в теле запроса:

type ShipOrderRequest struct {
    TrackingNumber string `json:"trackingNumber" validate:"required"`
    Carrier        string `json:"carrier"        validate:"required"`
}

func shipOrder(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")

    var req ShipOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }
    if err := validate.Struct(req); err != nil {
        var ve validator.ValidationErrors
        if errors.As(err, &ve) {
            writeValidationProblem(w, toViolations(ve), traceIDFromCtx(r.Context()))
            return
        }
        httperr.Write(w, r, err) // не ошибка полей, а сломанный вызов валидатора
        return
    }

    order, err := svc.ShipOrder(r.Context(), orderID, req.TrackingNumber, req.Carrier)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toOrderResponse(order))
}

Если параметров нет — тело пустое, декодировать нечего:

func confirmOrder(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")
    order, err := svc.ConfirmOrder(r.Context(), orderID)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toOrderResponse(order))
}

Action или PATCH — как выбрать

Простой ориентир:

  • Меняешь поле без бизнес-правил (описание, заметка) → PATCH.
  • Есть доменное имя для операции (confirm, ship, refund) → action.
  • Операция меняет состояние по машине состояний → action.
  • Есть побочные эффекты (события, уведомления, внешние вызовы) → action.
Дополнительно: при первом чтении можно пропустить

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

Команду легко отправить дважды: кнопку «Оплатить» нажали два раза, мобильная сеть оборвала ответ, и клиент повторил. Для action-эндпоинта это надо решить заранее. Команды с побочными эффектами (оплата, отправка) принимают Idempotency-Key, как описано в заголовках. Команды-переходы (confirm, cancel) чаще делают идемпотентными по состоянию: заказ уже подтверждён — второй confirm возвращает 200 и текущее состояние, а не создаёт второе событие. Переход, который из текущего состояния невозможен (ship для отменённого заказа), — это 409 Conflict с кодом вроде ORDER_STATE_CONFLICT.

Отдельная ловушка — команды-дельты: «плюс одна штука в корзину». На плохой сети ответ теряется, клиент повторяет, и количество скачет. Дельту заменяют абсолютным значением: PUT /cart/items/{productId} с телом {"quantity": 3} можно повторять сколько угодно с одним результатом. Если дельта по смыслу необходима (списание со счёта), её защищают тем же Idempotency-Key.

Коротко

  • me — alias текущего пользователя только в эндпоинтах с двумя вариантами: /{id} для администратора и /me для себя.
  • Singleton-эндпоинты (/profile, /settings) работают с вызывающим из токена — me не нужен.
  • Проверочный вопрос: «Может ли администратор обратиться по чужому ID?» Нет → me лишний.
  • Никогда не делай /api/v1/me без ресурсного префикса, только /api/v1/users/me.
  • Временные alias (latest, current, next) — для singleton-выборки «последнего» без фильтрации коллекции.
  • Логические alias (default, primary, active) — бизнес-шорткат на один объект по признаку.
  • Action-эндпоинты: POST /{resource}/{id}/{глагол}, глагол всегда в инфинитиве.
  • Метод action — всегда POST, даже если операция идемпотентна.
  • Команда с переходом состояния или побочными эффектами → action; простое поле без правил → PATCH.
  • Повтор команды — норма: переходы делают идемпотентными по состоянию (200 на повторный confirm, 409 на невозможный переход), дельты заменяют абсолютными значениями или закрывают Idempotency-Key.

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