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

Когда пишешь 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 /ordersgetOrders
GET /orders/{id}getOrder
POST /orderscreateOrder
PUT /orders/{id}updateOrder
PATCH /orders/{id}patchOrder
DELETE /orders/{id}deleteOrder
POST /orders/{id}/confirmconfirmOrder
POST /orders/searchsearchOrders
GET /products/{id}/price-historygetProductPriceHistory

Теги: группировка по ресурсу

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