Когда команда строит 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 и формат ответов — структура тела ответа.