Когда пишешь REST API на Go, есть два уровня вопросов. Первый — как правильно описать API в OpenAPI-спецификации: что такое operationId, зачем нужны теги, почему параметры пути должны быть уникальными. Второй — какие ошибки встречаются чаще всего и как их избежать.
Эта статья отвечает на оба вопроса.
Почему в Go нет автоматической генерации OpenAPI
В Java-фреймворках вроде Spring есть плагины, которые читают аннотации и сами строят OpenAPI-спецификацию. В Go такой встроенной механики нет — там структуры языка намного проще, без рефлексии на уровне аннотаций.
Есть два рабочих подхода:
Вариант А — swaggo/swag. Пишешь комментарии в специальном формате рядом с хендлером, запускаешь swag init — получаешь docs/swagger.json. Плюс: спека всегда рядом с кодом. Минус: комментарии дублируют структуры Go, нужно следить за синхронностью.
Вариант Б — ручная спека. Ведёшь openapi.yaml рядом с main.go. Структуры Go — источник правды; на ревью проверяется, что спека им соответствует.
Оба варианта рабочие. Главное — выбрать один и придерживаться его во всём сервисе.
operationId: имя для каждой операции
Представь, что ты пишешь SDK для своего API. SDK-генератор (например, openapi-generator) возьмёт operationId и сделает из него имя метода. Если operationId нет — генератор придумает что-то сам, например postApiV1OrdersOrderIdConfirm. Это нечитаемо и сломается при переименовании маршрута.
Правило простое: operationId — camelCase, формат действие + ресурс.
// createOrder godoc
// @Summary Создать заказ
// @Tags Orders
// @Accept json
// @Produce json
// @Param body body CreateOrderRequest true "Параметры заказа"
// @Success 201 {object} OrderResponse
// @Failure 400 {object} ValidationProblem
// @Failure 409 {object} ProblemDetails
// @Router /api/v1/orders [post]
// @operationId createOrder
func createOrder(w http.ResponseWriter, r *http.Request) { ... }
// confirmOrder godoc
// @Summary Подтвердить заказ
// @Tags Orders
// @Param orderId path string true "ID заказа"
// @Success 200 {object} OrderResponse
// @Failure 404 {object} ProblemDetails
// @Router /api/v1/orders/{orderId}/confirm [post]
// @operationId confirmOrder
func confirmOrder(w http.ResponseWriter, r *http.Request) { ... }
Конвенция именования:
| Операция | operationId |
|---|---|
GET /orders | getOrders |
GET /orders/{id} | getOrder |
POST /orders | createOrder |
PUT /orders/{id} | updateOrder |
PATCH /orders/{id} | patchOrder |
DELETE /orders/{id} | deleteOrder |
POST /orders/{id}/confirm | confirmOrder |
POST /orders/search | searchOrders |
GET /products/{id}/price-history | getProductPriceHistory |
Теги: группировка по ресурсу
Swagger UI показывает операции по тегам. Без тегов — плоский список из 50+ методов без навигации. Правило: один тег на ресурс, множественное число с заглавной буквы (Orders, Products, Customers). Action-эндпоинты (/confirm, /cancel) получают тег родительского ресурса.
// @Tags Orders ← createOrder, getOrder, confirmOrder, cancelOrder
// @Tags Products ← getProduct, createProduct, listProductReviews
// @Tags Customers ← getCustomer, verifyCustomer
В chi-роутере теги соответствуют блокам r.Route:
r.Route("/api/v1", func(r chi.Router) {
r.Route("/orders", func(r chi.Router) { // тег Orders
r.Get("/", listOrders)
r.Post("/", createOrder)
r.Route("/{orderId}", func(r chi.Router) {
r.Get("/", getOrder)
r.Post("/confirm", confirmOrder) // тоже тег Orders
})
})
r.Route("/products", func(r chi.Router) { // тег Products
r.Get("/", listProducts)
r.Get("/{id}", getProduct)
})
})
Параметры пути: {id} в chi и уникальные имена в OpenAPI
Есть тонкость с вложенными маршрутами. Если написать /orders/{id}/items/{id} — chi вернёт одно значение для обоих {id}. Это ошибка маршрутизации.
Правило: параметры пути называются уникально на всём маршруте.
// Правильно
r.Get("/orders/{orderId}/items/{itemId}", getOrderItem)
// Неправильно — два {id} в одном пути
r.Get("/orders/{id}/items/{id}", getOrderItem)
В OpenAPI-спеке параметры тоже называются уникально — это требование Swagger UI и Redoc: они не рендерят операцию корректно, если два параметра в одном пути называются одинаково.
/api/v1/orders/{orderId}/items/{itemId}:
get:
operationId: getOrderItem
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
- name: itemId
in: path
required: true
schema:
type: string
format: uuid
summary и description
summary — обязателен, до 80 символов. description — только если логика неочевидна: есть побочный эффект, поведение зависит от роли, или семантика непонятна из названия.
// getCustomerCardLimit godoc
// @Summary Получить лимит карты клиента
// @Description Возвращает текущий расходный лимит. Лимит пересчитывается ночью;
// в течение дня возвращается кешированное значение.
// @Tags Customers
// @Param customerId path string true "ID клиента"
// @Success 200 {object} CardLimitResponse
// @Router /api/v1/customers/{customerId}/card-limit [get]
// @operationId getCustomerCardLimit
func getCustomerCardLimit(w http.ResponseWriter, r *http.Request) { ... }
Пустой description лучше, чем дублирование summary.
Структуры Go как источник контракта
Независимо от выбранного подхода (swaggo или ручная спека), структуры response-типов определяют контракт:
type OrderResponse struct {
OrderID string `json:"orderId"`
Status string `json:"status"` // NEW | CONFIRMED | SHIPPED
CreatedAt time.Time `json:"createdAt"`
TotalRub int64 `json:"totalRub"` // копейки
Items []ItemResponse `json:"items"`
Note string `json:"note,omitempty"` // отсутствует если пусто
}
type PageResponse[T any] struct {
Content []T `json:"content"`
Page int `json:"page"`
Size int `json:"size"`
Total int `json:"total"`
}
omitempty на Note означает: поле отсутствует в JSON, когда пусто — нет "note": null, нет "note": "". Это правильное поведение для необязательных строковых полей.
Частые ошибки и как их исправить
URL и маршрутизация
Глагол в URL для CRUD. Пишут /getOrders, /createProduct — так делать не нужно. Глагол — это HTTP-метод: GET /orders, POST /products.
Неверный регистр в пути. Сегменты URL — kebab-case: /order-items, не /orderItems и не /order_items.
Trailing slash. В chi r.Get("/orders/", ...) и r.Get("/orders", ...) — разные маршруты. Регистрируй без завершающего слеша.
ID в теле запроса. Если ID ресурса есть в пути — доставай его через chi.URLParam(r, "orderId"), не из тела.
Три уровня вложенности. Маршрут /orders/{id}/items/{id}/shipments трудно читать и трудно версионировать. Если нужна третья вложенность — лучше вынести в отдельный ресурс: GET /shipments?itemId={id}.
GET с побочным эффектом. GET /orders/{id}/confirm — неверно. GET должен быть безопасным (safe): повторный вызов не меняет состояние. Действия делаются через POST: POST /orders/{id}/confirm.
Версионирование
Версия в query-параметре. ?version=2 — так не делают. Версия — в пути: /api/v2/.
Минорная версия. /api/v1.2/ — неверно. Версия меняется только при несовместимом изменении (breaking change), и всегда целое число.
Новая версия ради необязательного поля. Добавление необязательного поля — обратно совместимое изменение. Просто добавь поле с omitempty в текущую версию.
Query-параметры
Comma-separated массивы. ?status=NEW,PAID — неудобно парсить и ломает URL-кодирование. Правильно: повторять параметр — ?status=NEW&status=PAID, доставать через r.Form["status"].
Нулевая нумерация страниц. page=0 — неинтуитивно для клиентов. Начинай с page=1.
snake_case в параметрах. Параметры — camelCase: createdFrom, pageSize, не created_from, page_size.
Бизнес-логика в query. ?action=cancel — это скрытый action-эндпоинт. Правильно: POST /orders/{id}/cancel.
JSON и ответы
null в полях 2xx-ответа. Если поле необязательное — используй string с omitempty, не *string. Указатели (*string) тянут null в JSON — оставляй их только для PATCH-запросов, где null означает «очистить поле».
Envelope-обёртка. {"data": {...}, "success": true} — лишний уровень вложенности. Возвращай плоский объект.
null вместо пустого массива. Если у заказа нет позиций — отдавай "items": [], не "items": null. В Go инициализируй слайс явно: items := make([]ItemResponse, 0).
float64 для денег. Деньги в Go хранят и передают в целых числах (минорные единицы — копейки, центы): int64.
Ошибки
Неверный Content-Type для ошибок. Для ошибок используй application/problem+json (RFC 9457), не application/json.
about:blank в поле type. Поле type в ProblemDetails должно содержать URI, который идентифицирует тип ошибки: urn:problem:order-service:not-found. about:blank — заглушка, не информативная.
HTTP 422 для ошибок валидации. 422 не входит в стандартный набор кодов REST API. Для ошибок валидации — 400 Bad Request с code: VALIDATION_ERROR и списком нарушений в поле violations.
Строка из БД в detail. Никогда не кладёт текст ошибки из pgx/sqlc напрямую в ответ — он может содержать внутренние детали схемы. В detail — общая фраза на человеческом языке.
Разные структуры ошибок в разных хендлерах. Это хаос для клиентов. Один единый httperr.Write(w, r, err) через весь сервис.
Заголовки и дополнительные темы
X--префикс в кастомных заголовках. X-Request-Id — устаревшая конвенция (RFC 6648 отменил её в 2012). Используй доменный префикс: Shop-Request-Id.
429 без Retry-After. Если возвращаешь 429 Too Many Requests — middleware должен выставить заголовки Retry-After и RateLimit-*. Без них клиент не знает, когда повторить запрос.
deprecated: true без Sunset. Если помечаешь операцию устаревшей в OpenAPI — добавляй middleware с заголовками Sunset и Deprecation, чтобы клиенты получали сигнал в ответах, а не только в спецификации.
Коротко
operationId— camelCase, форматдействие + ресурс(createOrder,confirmOrder). Без него SDK-генераторы создают нечитаемые имена.- Один тег на ресурс, множественное число (
Orders,Products). Action-эндпоинты — тег родительского ресурса. - Параметры пути называются уникально:
{orderId},{itemId}— не два{id}в одном маршруте. summaryобязателен;description— только если есть что пояснить.- Необязательные поля в response-структурах —
omitempty, не указатели.*Tтолько в PATCH-запросах. - Ошибки валидации —
400, не422.Content-Type: application/problem+json, неapplication/json. - Деньги —
int64в минорных единицах.float64для денег не используют. - Единый
httperr.Writeчерез весь сервис — клиент получает предсказуемую структуру ошибки.
Что почитать дальше
- URL и ресурсы — kebab-case, вложенность,
chi.URLParam - Alias и action-эндпоинты —
me, action-эндпоинты,POST /confirm - Версионирование — breaking change, chi
r.Route("/api/v2", ...) - Query-параметры — camelCase,
r.Form["status"], cursor - JSON и формат ответов —
omitempty,PageResponse,int64 - Ошибки RFC 9457 —
ProblemDetails,httperr.Write,violations