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

Когда пишешь REST API, нужно договориться: в каком формате идут данные, какие HTTP-статусы возвращать и как не наплодить null там, где их никто не ждёт. Разберём всё по порядку.

Как Go превращает структуру в JSON

В Go любая структура превращается в JSON через поле json:"...". Имя поля в JSON задаётся тегом, а не именем поля в Go.

Проблема без тегов: Go экспортирует поля с заглавной буквы (OrderID, CreatedAt), и по умолчанию они попадут в JSON точно так же — OrderID, CreatedAt. REST API принято делать с camelCase: orderId, createdAt.

Решение — json-теги:

type OrderResponse struct {
    OrderID   string    `json:"orderId"`
    Status    string    `json:"status"`
    Total     int64     `json:"total"`
    CreatedAt time.Time `json:"createdAt"`
}

time.Time Go сериализует в ISO 8601 автоматически: "2026-03-14T09:00:00Z". Дополнительной настройки не нужно.

omitempty — как убрать пустые поля из ответа

Иногда поле необязательное: у заказа может не быть примечания. Если в Go это пустая строка "", без дополнительных настроек она всё равно попадёт в JSON: "note": "". Это лишний шум в ответе.

Добавь omitempty — и поле просто исчезнет из JSON, когда оно пустое:

type OrderResponse struct {
    OrderID string `json:"orderId"`
    Note    string `json:"note,omitempty"` // не появится, если ""
}

omitempty считает «пустым»: пустую строку "", ноль 0, false и nil. Для строк и чисел это обычно именно то, что нужно.

Почему не надо использовать указатели в ответах

Иногда в ответных структурах пишут *string (указатель на строку). Проблема: когда значения нет, Go сериализует указатель как null. И клиент получает "note": null там, где ожидал просто отсутствие поля.

// частая ошибка — null попадёт в ответ
type Bad struct {
    Note *string `json:"note"`
}

// правильно — поле просто отсутствует
type Good struct {
    Note string `json:"note,omitempty"`
}

Исключение: в структурах для PATCH-запросов указатель нужен, чтобы различить «поле не передали» и «передали null намеренно, чтобы удалить значение». Но это только для входящих данных, не для ответов.

Хелперы для записи ответа

Вместо того чтобы в каждом обработчике повторять одни и те же строки, заведи пару маленьких функций:

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        slog.Error("writeJSON failed", "err", err)
    }
}

func writeNoContent(w http.ResponseWriter) {
    w.WriteHeader(http.StatusNoContent)
}

Теперь в обработчиках — одна строка вместо трёх.

Единичный ресурс — просто плоский объект

Когда клиент запрашивает один заказ, ответ — просто объект без лишних обёрток:

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

JSON-ответ выглядит так:

{
  "orderId": "a3f2d1",
  "customerId": "c-101",
  "status": "CONFIRMED",
  "total": 49900,
  "currency": "RUB",
  "createdAt": "2026-03-14T09:00:00Z",
  "updatedAt": "2026-03-14T09:05:00Z",
  "items": [...]
}

Распространённая ошибка — завернуть объект в обёртку: {"data": {...}}. Это усложняет клиентский код без пользы. Отдавай объект напрямую.

Коллекция с пагинацией

Когда нужно вернуть список с постраничной навигацией, используют единый формат: массив в поле content плюс метаданные о странице.

type PageResponse[T any] struct {
    Content []T `json:"content"`
    Page    int `json:"page"`
    Size    int `json:"size"`
    Total   int `json:"total"`
}

func toPageResponse[T any](items []T, page, size, total int) PageResponse[T] {
    if items == nil {
        items = []T{}  // пустой срез, не nil
    }
    return PageResponse[T]{
        Content: items,
        Page:    page,
        Size:    size,
        Total:   total,
    }
}

Ответ выглядит так:

{"content": [...], "page": 1, "size": 20, "total": 150}

Важный момент: если список пустой, content должен быть [], а не null:

{"content": [], "page": 1, "size": 20, "total": 0}

Вот почему в toPageResponse есть проверка if items == nil — неинициализированный срез в Go сериализуется в null. Это защита от случайной ошибки.

Создание ресурса — 201 и заголовок Location

Когда клиент создаёт новый ресурс, правильный ответ — 201 Created. Дополнительно нужно вернуть заголовок Location с адресом созданного ресурса — так клиент сразу знает, куда идти за ним.

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

    order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
    if err != nil {
        httperr.Write(w, r, err)
        return
    }

    w.Header().Set("Location", "/api/v1/orders/"+order.ID)
    writeJSON(w, http.StatusCreated, toOrderResponse(order))
}

Часто вижу 200 OK на создание — это неверно. 200 означает «запрос выполнен, ресурс уже существовал». 201 говорит «создан новый ресурс».

Обновление и удаление

При обновлении через PUT или PATCH возвращай 200 OK и обновлённый ресурс:

func patchOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    var req PatchOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }
    order, err := svc.PatchOrder(r.Context(), id, req.Note)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toOrderResponse(order))
}

При удалении — 204 No Content с пустым телом:

func deleteOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    if err := svc.DeleteOrder(r.Context(), id); err != nil {
        httperr.Write(w, r, err)
        return
    }
    writeNoContent(w)
}

Деньги и перечисления

Два момента, где часто делают ошибки:

Деньги. Никогда не используй float64 для денежных значений — плавающая точка накапливает погрешность. Используй int64 в минорных единицах: копейках, центах. Значение 49900 в ответе означает 499,00 ₽. Клиент делит на 100 при отображении.

Перечисления. Статусы и категории отдавай строками в верхнем регистре: "NEW", "CONFIRMED", "SHIPPED". Это читаемо и однозначно.

type OrderResponse struct {
    Status   string `json:"status"`   // "NEW", "CONFIRMED", "SHIPPED"
    Total    int64  `json:"total"`    // 49900 = 499.00 RUB
    Currency string `json:"currency"` // "RUB"
}

Типичные ошибки

  • snake_case в json-тегах (order_id вместо orderId) — REST API договорились на camelCase.
  • *string в ответных структурах без omitempty — клиент получает "note": null.
  • {"data": {...}} — обёртка вокруг объекта усложняет клиентский код.
  • "content": null при пустой коллекции — должен быть [].
  • float64 для денег — используй int64 в минорных единицах.
  • 200 OK на создание — должен быть 201 Created с Location.

Коротко

  • JSON-теги задают имена полей: json:"orderId" даёт camelCase в ответе.
  • omitempty убирает пустые поля из JSON — не нужны null в 2xx-ответах.
  • В ответных структурах избегай *T — указатели дают null; используй значение + omitempty.
  • Коллекция с пагинацией: {"content": [...], "page": 1, "size": 20, "total": 150}.
  • Пустая коллекция — "content": [], никогда null.
  • Создание — 201 Created + заголовок Location + тело ресурса.
  • Удаление — 204 No Content, тело пустое.
  • Деньги — int64 в копейках/центах, не float64.
  • Перечисления — строки в верхнем регистре: "NEW", "CONFIRMED".

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

  • Query-параметры — как принимать фильтры и параметры страницы.
  • Ошибки RFC 9457 — как устроен ответ при ошибке.
  • Заголовки — Location, Content-Type, Idempotency-Key.