Когда проектируешь 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 {
writeValidationProblem(w, toViolations(err.(validator.ValidationErrors)),
traceIDFromCtx(r.Context()))
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.
Коротко
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.
Что почитать дальше
- URL и ресурсы — базовая структура маршрутов в chi.
- JSON и формат ответов — как оформить ответ
200 OKдля action. - Ошибки RFC 9457 — как вернуть ошибку при конфликте состояния (
409).