Опирается на правила: R-URL-1..4, R-URL-X1..X4, R-RES-1..3, R-RES-X1..X2, R-NEST-1..4, R-NEST-X1..X3раздел URL и ресурсы.

Важно знать

  • Только строчные буквы, разделитель слов — дефис (kebab-case): /sales-orders, /payment-methods.
  • chi не редиректит trailing slash по умолчанию — не добавляй его в маршруты.
  • Коллекция — существительное во множественном числе: /orders, /products, /customers.
  • Singleton в контексте родителя — единственное число: /orders/{id}/summary.
  • Максимум два уровня вложенности; третий уровень — ресурс на верхний уровень с фильтром.
  • Path-параметр в дизайне — {id}; в chi читается через chi.URLParam(r, "id").
  • В OpenAPI параметры именуются уникально: {orderId}, {itemId}.
  • Служебные эндпоинты (/health, /metrics) — вне /api/v1.

Структура маршрутов в chi

R-URL-1..3 и R-NEST-1:

r := chi.NewRouter()

r.Get("/health", healthHandler)  // вне /api/v1 (R-URL-3)

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/orders", func(r chi.Router) {
        r.Get("/", listOrders)
        r.Post("/", createOrder)
        r.Post("/search", searchOrders)
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getOrder)
            r.Put("/", updateOrder)
            r.Patch("/", patchOrder)
            r.Delete("/", deleteOrder)
            r.Post("/confirm", confirmOrder)
            r.Post("/cancel", cancelOrder)
            r.Route("/items", func(r chi.Router) {  // два уровня — ок
                r.Get("/", listOrderItems)
                r.Post("/", addOrderItem)
            })
        })
    })

    r.Route("/products", func(r chi.Router) {
        r.Get("/", listProducts)
        r.Post("/", createProduct)
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getProduct)
            r.Put("/", updateProduct)
            r.Delete("/", deleteProduct)
        })
    })
})

Чтение path-параметра

R-NEST-4: в дизайне URL используется {id}, контекст из сегмента устраняет неоднозначность.

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

func getOrderItem(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")
    itemID  := chi.URLParam(r, "id")  // в разных уровнях r.Route — разные контексты
    // ...
}

Вложенный /orders/{id}/items читает chi.URLParam из контекста правильно — chi пишет параметры в контекст послойно.

Два уровня вложенности

R-NEST-1: третий уровень выносится наверх с фильтром.

// два уровня — ок
r.Get("/orders/{id}/items", listOrderItems)

// три уровня — нарушение R-NEST-X1
// r.Get("/orders/{id}/items/{id}/shipments", ...)  ✗

// вместо этого:
r.Get("/shipments", listShipments)  // ?itemId=...
func listShipments(w http.ResponseWriter, r *http.Request) {
    itemID := r.URL.Query().Get("itemId")
    // ...
}

Именование ресурсов

R-RES-1..3: доменные термины, множественное число для коллекций.

// коллекции — множественное число
r.Route("/orders", ...)        // ✓
r.Route("/products", ...)      // ✓
r.Route("/customers", ...)     // ✓
r.Route("/payment-methods", .) // ✓ kebab-case для двух слов

// singleton в контексте родителя — единственное число
r.Get("/orders/{id}/summary", getOrderSummary)  // ✓
r.Get("/customers/{id}/profile", getCustomerProfile)  // ✓

Запросы, не укладывающиеся в CRUD

Для сложной фильтрации — POST /resources/search с JSON-телом (R-QRY-9):

r.Post("/orders/search", searchOrders)

type SearchOrdersRequest struct {
    CustomerID string   `json:"customerId"`
    Statuses   []string `json:"statuses"`
    AmountFrom int64    `json:"amountFrom"`
    AmountTo   int64    `json:"amountTo"`
}

func searchOrders(w http.ResponseWriter, r *http.Request) {
    var req SearchOrdersRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }
    // ...
}

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

АнтипаттернПравилоЧто взамен
/Orders, /salesOrders, /sales_ordersR-URL-X1/orders, /sales-orders
/api/v1/orders/ (trailing slash)R-URL-X2/api/v1/orders
/api/v1/orders.jsonR-URL-X3/api/v1/orders + Accept: application/json
/api/v1/getOrders, /api/v1/createOrderR-URL-X4GET /api/v1/orders, POST /api/v1/orders
/api/v1/order (единственное число)R-RES-X1/api/v1/orders
/api/v1/orders/{id}/items/{id}/shipmentsR-NEST-X1/api/v1/shipments?itemId=
ID родителя в теле вместо путиR-NEST-X2POST /api/v1/orders/{id}/items
/api/v1/orders/{orderId}/items/{itemId} в дизайнеR-NEST-X3{id} в дизайне, уникальные имена только в OpenAPI

Куда дальше

  • go/versioning.md — /api/v1 prefix, breaking change.
  • go/query-params.md — параметры фильтрации и поиска.
  • go/alias-and-actions.md — me, latest, action-эндпоинты.
  • go/json-and-responses.md — формат тела ответа.
  • go/errors.md — коды ошибок при неверном URL.