Опирается на правила:
R-ALIAS-1..3,R-ALIAS-X1..X2,R-ACT-1..4,R-ACT-X1..X2→ раздел Alias и Action-эндпоинты.
Важно знать
me— только когда endpoint принимает и свой, и чужой ID (admin scope).- Тест: «может ли супер-админ обратиться по другому ID?» Да →
meнужен.- Singleton (
/profile,/settings) работает с ресурсами вызывающего из токена —meне нужен.- Временные alias (
latest,current) — для singleton-выборки, не для фильтрации.- Логические alias (
default,primary,active) — бизнес-shortcut на один объект.- Action-эндпоинты —
POST /{resource}/{id}/{action}, глагол в инфинитиве.- Метод action — всегда
POST, даже если операция идемпотентна./meбезusers/префикса — запрещён.
Alias-сегменты
me
R-ALIAS-1: alias для текущего пользователя в эндпоинтах с dual scope.
r.Route("/api/v1", func(r chi.Router) {
r.Route("/users", func(r chi.Router) {
r.Get("/{id}", getUser) // admin GET /users/42
r.Get("/me", getMyUser) // user GET /users/me — alias
r.Put("/{id}", updateUser) // admin PUT /users/42
r.Put("/me", updateMyUser) // user PUT /users/me
})
})
Реализация getMyUser — читает ID из токена:
func getMyUser(w http.ResponseWriter, r *http.Request) {
userID := mustUserID(r.Context()) // из JWT/session middleware
user, err := svc.GetUser(r.Context(), userID)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toUserResponse(user))
}
getUser принимает {id} и проверяет права — admin может смотреть любого:
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))
}
Временные и порядковые alias
R-ALIAS-2: shortcut на singleton вместо сортировки+лимита.
r.Get("/products/{id}/latest-review", getLatestReview)
r.Get("/subscriptions/current", getCurrentSubscription)
r.Get("/deployments/latest", getLatestDeployment)
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
R-ALIAS-3: бизнес-singleton по признаку.
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))
}
Action-эндпоинты
Доменные команды, меняющие состояние агрегата.
Регистрация в chi
R-ACT-1..3:
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) // ✓
})
})
Обработчик с параметрами в теле
R-ACT-4:
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))
}
Если параметров нет — тело пустое, json.Decode не вызывается:
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 vs PATCH
| Ситуация | Что выбрать |
|---|---|
| Меняем поле, нет бизнес-правил | PATCH |
| Команда с side-effects (события, переходы) | Action |
Доменное имя есть (confirm, ship, refund) | Action |
State machine (status) | Action |
Меняем description, note | PATCH |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
me на singleton-эндпоинтах | R-ALIAS-X1 | singleton без me (/profile) |
/api/v1/me без users/ | R-ALIAS-X2 | /api/v1/users/me |
/orders/{id}/confirmation | R-ACT-X1 | /orders/{id}/confirm |
/orders/{id}/confirmed | R-ACT-X1 | /orders/{id}/confirm |
r.Put("/orders/{id}/confirm", ...) | R-ACT-X2 | r.Post(...) |
PATCH /orders/{id} {status: CANCELLED} для команды | R-ACT-1 | POST /orders/{id}/cancel |
Куда дальше
- go/url-and-resources.md — базовая структура маршрутов.
- go/json-and-responses.md — формат
200 OKв ответе action. - go/errors.md — ошибки при конфликте состояния (
409). - go/openapi-and-antipatterns.md —
operationIdдля action.