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

Когда команда строит API, один разработчик пишет /getOrders, другой /orders/list, третий /Orders. Все три делают одно и то же, но клиенту и коллегам приходится угадывать. Разберём правила, которые делают URL предсказуемыми.

Почему URL должны следовать соглашению

Представьте, что вы пришли в магазин, а все ценники написаны по-разному: один на английском, другой с заглавной буквы, третий со словом «get» в начале. Непонятно, где что искать.

REST-URL — это адрес ресурса, не команда. Адрес /orders говорит «здесь живут заказы». HTTP-метод (GET, POST, DELETE) уже объясняет, что делать с ними. Поэтому в URL не нужны глаголы вроде getOrders или createProduct.

Формат URL: строчные буквы и дефис

Правило простое: всё строчными буквами, слова разделяются дефисом.

/orders           ✓
/payment-methods  ✓
/sales-orders     ✓

/Orders           ✗  (заглавная буква)
/paymentMethods   ✗  (camelCase)
/payment_methods  ✗  (underscore)

Дефис выбран потому, что поисковики и браузеры правильно его понимают как разделитель слов. Underscore иногда теряется под ссылкой в тексте и хуже индексируется.

Trailing slash в chi

chi по умолчанию не перенаправляет /orders/ на /orders. Если в коде написано r.Get("/orders", handler), то запрос на /orders/ вернёт 404. Поэтому просто не добавляйте trailing slash в маршруты.

Коллекции и одиночные ресурсы

Коллекция — существительное во множественном числе:

/orders       — все заказы
/products     — все товары
/customers    — все покупатели

Единственное число используется только для «одиночного» подресурса внутри родителя, у которого не может быть нескольких:

/orders/{id}/summary   — одна сводка по заказу
/customers/{id}/profile — один профиль покупателя

Не путайте: /order (без s) для коллекции — частая ошибка. Коллекция всегда во множественном числе.

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

chi позволяет группировать маршруты через r.Route. Вот типичная структура:

r := chi.NewRouter()

r.Get("/health", healthHandler)  // служебный эндпоинт — вне /api/v1

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)
        })
    })
})

Служебные эндпоинты (/health, /metrics, /ready) находятся вне /api/v1 — они не часть бизнес-API.

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

В маршруте параметр обозначается фигурными скобками: {id}. Внутри обработчика его читают через chi.URLParam:

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))
}

Когда в пути два разных параметра, им дают уникальные имена — {orderId} и {itemId}. Если оба назвать {id}, chi вернёт одно и то же значение для обоих вызовов chi.URLParam(r, "id"), и это трудно заметить.

Максимум два уровня вложенности

Вложенность показывает принадлежность: /orders/{id}/items — позиции конкретного заказа. Но если углубиться до трёх уровней, URL становится длинным и хрупким: при любом переезде ресурса все клиенты сломаются.

Правило: не больше двух уровней. Если нужен третий — выносите ресурс на верхний уровень и передавайте связь через query-параметр:

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

// три уровня — слишком глубоко
// r.Get("/orders/{id}/items/{itemId}/shipments", ...)

// вместо этого — отдельный ресурс с фильтром
r.Get("/shipments", listShipments)  // ?itemId=...
func listShipments(w http.ResponseWriter, r *http.Request) {
    itemID := r.URL.Query().Get("itemId")
    // ...
}

Сложная фильтрация — POST /search

Если для поиска нужно передать много условий — статусы, диапазоны дат, несколько идентификаторов — query-строка становится неудобной. В таком случае делают отдельный маршрут POST /resources/search с JSON-телом:

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
    }
    // ...
}

Частые ошибки

Глаголы в URL. /getOrders, /createOrder, /deleteProduct — нарушение принципа REST. Используйте HTTP-метод: GET /orders, POST /orders, DELETE /products/{id}.

Расширение в URL. /orders.json — формат указывается заголовком Accept: application/json, а не суффиксом пути.

Единственное число для коллекций. /order вместо /orders — клиент не поймёт, один это заказ или список.

Три и более уровней вложенности. /orders/{id}/items/{itemId}/shipments — выносите shipments на верхний уровень.

Коротко

  • URL строчными буквами, слова через дефис: /payment-methods, /sales-orders.
  • Коллекция — всегда во множественном числе: /orders, /products.
  • Одиночный подресурс внутри родителя — единственное число: /orders/{id}/summary.
  • chi не перенаправляет trailing slash — не добавляйте его в маршруты.
  • Служебные эндпоинты (/health, /metrics) — вне /api/v1.
  • Вложенность — максимум два уровня; третий уровень выносите наверх с фильтром.
  • Два параметра в пути — давайте уникальные имена: {orderId} и {itemId}.
  • Сложная фильтрация — POST /resources/search с JSON-телом.

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

  • Версионирование — как строить /api/v1 и управлять обратной совместимостью.
  • Query-параметры — фильтрация, пагинация, сортировка.
  • Alias и Action-эндпоинты — /me, /latest, нестандартные действия.
  • JSON и формат ответов — структура тела ответа.