Опирается на правила: 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, notePATCH

Что запрещено

АнтипаттернПравилоЧто взамен
me на singleton-эндпоинтахR-ALIAS-X1singleton без me (/profile)
/api/v1/me без users/R-ALIAS-X2/api/v1/users/me
/orders/{id}/confirmationR-ACT-X1/orders/{id}/confirm
/orders/{id}/confirmedR-ACT-X1/orders/{id}/confirm
r.Put("/orders/{id}/confirm", ...)R-ACT-X2r.Post(...)
PATCH /orders/{id} {status: CANCELLED} для командыR-ACT-1POST /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.