Перейти к содержанию

HTTP API

Правила для HTTP-транспорта: роутер, middleware, формат request/response, пагинация, health и metrics endpoint'ы. Все сервисы следуют этим правилам — клиенту не нужно помнить, как именно отдаёт ошибки каждый конкретный сервис.

Путь одного запроса сверху вниз:

flowchart TD
    C[Client / Gateway]
    C -->|HTTP| MW1[RequestID]
    MW1 --> MW2[RealIP]
    MW2 --> MW3[Recoverer]
    MW3 --> MW4[Logger]
    MW4 --> MW5[Timeout 30s]
    MW5 --> MW6{"Route match?"}
    MW6 -->|/v1/*| AUTH[GatewayAuth<br/>HMAC headers]
    MW6 -->|/internal/*| ITK[InternalToken]
    MW6 -->|/healthz<br/>/readyz<br/>/metrics| NOAUTH[skip auth]
    AUTH --> RL[RateLimit]
    ITK --> H[handler]
    RL --> H
    NOAUTH --> H
    H -->|service err| MAP[mapServiceError]
    H -->|success| RESP["writeJSON<br/>{data: ...}"]
    MAP --> ERR["writeError<br/>{error: {code, message, request_id}}"]

Middleware порядок строгий — ошибка в порядке часто ломает корреляцию в логах или приводит к тому, что handler отвечает после deadline.

Содержание

Роутер

Стандарт — chi. Никаких других роутеров (gorilla/mux, gin, echo) в проекте не заводим.

Корневой роутер собирается в internal/handler/router.go:

package handler

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func NewRouter(d Deps) http.Handler {
    r := chi.NewRouter()

    r.Use(chimw.RequestID)
    r.Use(chimw.RealIP)
    r.Use(chimw.Recoverer)
    r.Use(chimw.Logger)
    r.Use(chimw.Timeout(30 * time.Second))

    // health / meta — без auth
    r.Get("/healthz", d.Health.Live)
    r.Get("/readyz", d.Health.Ready)
    r.Handle("/metrics", promhttp.Handler())

    // /v1/* — публичный API через gateway
    r.Route("/v1", func(r chi.Router) {
        r.Use(middleware.GatewayAuth(d.Cfg.Gateway.HMACKey))
        r.Mount("/reviews", d.Review.Routes())
        r.Mount("/profile", d.Profile.Routes())
    })

    // /internal/* — сервис-к-сервису
    r.Route("/internal", func(r chi.Router) {
        r.Use(middleware.InternalToken(d.Cfg.Internal.APIToken))
        r.Get("/reviews/{id}", d.Internal.GetReview)
    })

    return r
}

Sub-router одного ресурса группируется методом Mount или Route. Каждый handler-пакет возвращает chi.Router или функцию, принимающую chi.Router — не хардкодь пути в разных местах.

Порядок middleware

Middleware на корневом роутере подключаются строго в таком порядке:

RequestID → RealIP → Recoverer → Logger → Timeout → Auth → RateLimit → handler
Middleware Почему здесь
RequestID Первым — иначе последующие middleware не смогут писать request-id в логи / header'ы.
RealIP До Logger — иначе в лог попадёт адрес прокси, а не клиента.
Recoverer До всего бизнес-кода — чтобы panic из любого нижнего middleware/handler превращался в 500, а не валил процесс.
Logger После Recoverer — чтобы паники логировались как ошибки, не оставляя «дыр» в access-логе.
Timeout До Auth — чтобы медленный токен-чекер не держал запрос дольше SLA.
Auth После Timeout — иначе за таймаут ответим 504, хотя клиент прислал невалидный токен.
RateLimit После Auth — считаем лимиты per-user, а не per-IP, где это возможно.

Порядок внутри sub-router'ов (например, /v1/admin): GatewayAuth → RequireRole → RateLimit (если нужен) → handler.

Декодирование запроса

JSON-тело

func decodeJSON(r *http.Request, v any) error {
    r.Body = http.MaxBytesReader(nil, r.Body, 64<<10)
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    return dec.Decode(v)
}
  • MaxBytesReader ограничивает размер тела — без него злонамеренный клиент может отправить гигабайт JSON и съесть память.
  • DisallowUnknownFields() отклоняет запросы с опечатками ("rating""ratting"). Без этого опечатка клиента молча потеряет поле.
  • Для POST/PUT/PATCH проверяй Content-Type: если не начинается с application/json — отвечай 415.

Query и path параметры

// path: /v1/reviews/{id}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
    writeError(w, http.StatusBadRequest, "invalid_id", "review id must be integer")
    return
}

// query: ?limit=20&cursor=abc
limitStr := r.URL.Query().Get("limit")
cursor := r.URL.Query().Get("cursor")

Никакой «авто-парсинг» query-параметров через магические библиотеки — только r.URL.Query().Get() и явный парсинг в нужный тип.

Валидация

Стандарт — github.com/go-playground/validator/v10. Теги в struct:

type CreateReviewRequest struct {
    PlaceID int64  `json:"place_id" validate:"required,gt=0"`
    Rating  int16  `json:"rating"   validate:"required,min=1,max=5"`
    Text    string `json:"text"     validate:"max=2000"`
}

Один глобальный экземпляр на пакет:

var validatorInstance = validator.New(validator.WithRequiredStructEnabled())

func validate(in any) error {
    return validatorInstance.Struct(in)
}

Ошибки валидации → 400 с деталями по каждому невалидному полю:

if err := validate(req); err != nil {
    var verrs validator.ValidationErrors
    if errors.As(err, &verrs) {
        writeValidationError(w, verrs)
        return
    }
    writeError(w, http.StatusBadRequest, "invalid_request", "validation failed")
    return
}

writeValidationError отдаёт список {"field": "rating", "rule": "max", "param": "5"}.

Формат ответа

Успех

{
  "data": {
    "id": 42,
    "rating": 5,
    "text": "ok"
  }
}

Для списков data — массив:

{
  "data": [ {"id": 1}, {"id": 2} ],
  "next_cursor": "eyJsYXN0X2lkIjoyfQ=="
}

Ошибка

{
  "error": {
    "code": "invalid_credentials",
    "message": "invalid login or password",
    "request_id": "01HZ3G..."
  }
}
  • code — машиночитаемый, snake_case, стабильный (клиент может на него ветвиться).
  • message — человекочитаемый, без внутренних деталей (см. ../conventions/security.md).
  • request_id — из X-Request-Id header'а, чтобы клиент мог сослаться на конкретный запрос в support-тикете.

Маппинг ошибок

Service-слой возвращает sentinel-ошибки. Handler превращает их в HTTP-статус через один централизованный маппер:

func mapServiceError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, service.ErrNotFound):
        writeError(w, http.StatusNotFound, "not_found", err.Error())
    case errors.Is(err, service.ErrValidation):
        writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
    case errors.Is(err, service.ErrUnauthorized):
        writeError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
    case errors.Is(err, service.ErrForbidden):
        writeError(w, http.StatusForbidden, "forbidden", "forbidden")
    case errors.Is(err, service.ErrConflict):
        writeError(w, http.StatusConflict, "conflict", err.Error())
    case errors.Is(err, service.ErrRateLimit):
        writeError(w, http.StatusTooManyRequests, "rate_limited", "too many requests")
    default:
        writeError(w, http.StatusInternalServerError, "internal", "internal error")
    }
}

Таблица соответствия:

Sentinel из service HTTP code
ErrNotFound 404 not_found
ErrValidation 400 invalid_input
ErrUnauthorized 401 unauthorized
ErrForbidden 403 forbidden
ErrConflict 409 conflict
ErrRateLimit 429 rate_limited
default 500 internal

Никаких http.StatusX констант прямо в handler-коде. Хочешь новый статус — добавь sentinel в service и правило в mapServiceError.

Логирование запросов

Access-лог пишется middleware (chimw.Logger или кастомным), не в каждом handler'е. Handler пишет только бизнес-события (slog.Info("review created", "review_id", r.ID)), не «got request for /v1/...».

Формат, поля и маскирование PII — см. logging.md.

Пагинация

Cursor-based — по умолчанию

GET /v1/reviews?cursor=<opaque>&limit=20
{
  "data": [ ... ],
  "next_cursor": "eyJsYXN0X2lkIjo0Mn0="
}
  • cursor — base64-encoded JSON или компактная opaque-строка. Содержимое — деталь реализации сервера, клиент её не парсит.
  • limit — число, максимум 100, дефолт 20. Если клиент прислал больше — отвечаем 400.
  • Пустой next_cursor (или отсутствует) — значит, страниц больше нет.

Cursor должен быть устойчив к параллельным вставкам: кодируй в него (created_at, id) или аналог, чтобы WHERE (created_at, id) < (cursor) давал детерминированный порядок.

Cursor vs offset: почему именно cursor

Cursor-based Offset-based
Производительность на глубине WHERE (cts, id) < (...) + LIMIT → index scan, O(limit) OFFSET 100000 LIMIT 20 → index scan + skip 100k, O(offset)
При вставке новых строк между страницами Стабилен: показываешь ровно то, что было «до курсора» Сдвиг: новая строка вставилась → страница 3 показывает часть того, что было на странице 2
Duplicate/skip Не бывает (курсор — точка в упорядоченном потоке) Бывает: на границах страниц при вставках/удалениях
«Прыгнуть на страницу 50» Нельзя (только вперёд через next) Можно
Deep pagination на больших таблицах Работает на 10M+ строках Падает по latency на 100k+ OFFSET

Deep pagination — практическая причина, почему cursor дефолтный. На таблице reviews в 50M строк OFFSET 100000 LIMIT 20 с ORDER BY created_at DESC занимает ~4 секунды; cursor — ~20ms при том же индексе.

Реализация cursor

Cursor кодирует последнюю возвращённую строку. Для ORDER BY created_at DESC:

type cursor struct {
    CreatedAt time.Time `json:"c"`
    ID        int64     `json:"i"`
}

func encodeCursor(c cursor) string {
    b, _ := json.Marshal(c)
    return base64.RawURLEncoding.EncodeToString(b)
}

func decodeCursor(s string) (*cursor, error) {
    if s == "" { return nil, nil }
    b, err := base64.RawURLEncoding.DecodeString(s)
    if err != nil { return nil, err }
    var c cursor
    if err := json.Unmarshal(b, &c); err != nil { return nil, err }
    return &c, nil
}

Запрос:

const q = `
    SELECT id, rating, text, created_at
      FROM review.reviews
     WHERE place_id = $1
       AND deleted_at IS NULL
       AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
     ORDER BY created_at DESC, id DESC
     LIMIT $4
`
rows, err := pool.Query(ctx, q, placeID, cursor.CreatedAt, cursor.ID, limit+1)

Мы запрашиваем limit+1 строк — если вернулось limit+1, значит есть следующая страница, возвращаем первые limit и cursor от последней из них.

Индекс под такой запрос — композитный по (place_id, created_at DESC, id DESC) с WHERE deleted_at IS NULL. Без него запрос деградирует до seq scan на больших таблицах.

Offset-based — только для admin

GET /v1/admin/users?page=3&per_page=50

Допустим только на admin/backoffice-endpoint'ах, где ожидается малая аудитория, маленькая таблица (< 100k строк) и клиенту нужна возможность «прыгнуть» на конкретную страницу. На публичных endpoint'ах — строго cursor-based.

N+1 при api-composition

List-view часто собирает данные из нескольких сервисов: список reviews + автор каждого ревью. Наивная имплементация:

// ПЛОХО: N+1
reviews, _ := r.reviews.List(ctx, placeID, limit)
for i, rev := range reviews {
    user, _ := r.userClient.Get(ctx, rev.UserID)  // HTTP-запрос на каждый
    reviews[i].Author = user
}

На 20 reviews → 21 HTTP-запрос, latency = N × RTT + host connection pool contention.

Правильно — batch-запрос:

reviews, _ := r.reviews.List(ctx, placeID, limit)

userIDs := make([]int64, 0, len(reviews))
for _, rev := range reviews { userIDs = append(userIDs, rev.UserID) }

users, _ := r.userClient.GetBatch(ctx, userIDs)  // один HTTP-запрос
userMap := make(map[int64]*User, len(users))
for _, u := range users { userMap[u.ID] = u }

for i, rev := range reviews {
    reviews[i].Author = userMap[rev.UserID]
}

Требует, чтобы downstream-сервис выставлял /internal/users:batch endpoint (GET /internal/users?ids=1,2,3 или POST /internal/users:batch с body {ids: [...]}). Это требование архитектуры — любой сервис, на данные которого делается api-composition, обязан дать batch-endpoint.

Параллельные запросы через errgroup — когда нужно склеивать данные из двух и более downstream-сервисов:

g, gctx := errgroup.WithContext(ctx)
var users []*User
var places []*Place
g.Go(func() error {
    u, err := r.userClient.GetBatch(gctx, userIDs)
    users = u; return err
})
g.Go(func() error {
    p, err := r.placeClient.GetBatch(gctx, placeIDs)
    places = p; return err
})
if err := g.Wait(); err != nil { return nil, err }

Оба запроса идут одновременно — latency = max(user, place) вместо user + place.

Подробно — ../patterns/api-composition.md.

Фильтрация и сортировка

Сортировка

GET /v1/reviews?sort=-created_at,rating

Формат: список полей через запятую. Префикс - — desc, без префикса — asc. Поля, по которым разрешена сортировка, — whitelist в handler'е; всё остальное → 400. Не пробрасывай ?sort= прямо в SQL.

Фильтры

GET /v1/reviews?filter[status]=active&filter[place_id]=42

Парсинг:

status := r.URL.Query().Get("filter[status]")
placeIDStr := r.URL.Query().Get("filter[place_id]")

Разрешённые ключи фильтров — тоже whitelist. Ошибка в ключе → 400.

Content-Type

  • POST, PUT, PATCH с телом — требуем Content-Type: application/json. Иное → 415 Unsupported Media Type.
  • GET, DELETE — тело игнорируется (даже если пришло).
  • Ответ всегда Content-Type: application/json; charset=utf-8.

Таймауты

На корневом роутере:

r.Use(chimw.Timeout(30 * time.Second))

Handler видит ctx.Done() по истечении и обязан учитывать это в долгих операциях (SQL, HTTP-клиенты, Kafka). Не игнорируй ctx в handler'е.

На HTTP-сервере тоже явные таймауты:

srv := &http.Server{
    Addr:              cfg.Service.Addr(),
    Handler:           router,
    ReadHeaderTimeout: 10 * time.Second,
    ReadTimeout:       30 * time.Second,
    WriteTimeout:      60 * time.Second,
    IdleTimeout:       120 * time.Second,
}

Без ReadHeaderTimeout сервис уязвим к slowloris-атакам (клиент тянет headers вечно).

Health endpoints

  • /healthzliveness. Просто возвращает 200 OK, если процесс жив. Не проверяет БД/Redis/Kafka: перезапускать pod при сетевом глюке соседа нельзя.
  • /readyzreadiness. Проверяет, что сервис готов обслуживать трафик: pool.Ping, redis.Ping, Kafka-broker reachable. При любой недоступности — 503.

Оба endpoint'а — не под auth. Kubernetes probe ходит без headers.

func (h *HealthHandler) Live(w http.ResponseWriter, _ *http.Request) {
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()
    if err := h.pool.Ping(ctx); err != nil {
        writeError(w, http.StatusServiceUnavailable, "db_unavailable", "db unavailable")
        return
    }
    if err := h.kafka.Ping(ctx); err != nil {
        writeError(w, http.StatusServiceUnavailable, "kafka_unavailable", "kafka unavailable")
        return
    }
    writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

Metrics endpoint

r.Handle("/metrics", promhttp.Handler())
  • Не под auth: внутренний Prometheus ходит без токена.
  • Gateway не роутит /metrics наружу. В публичный Интернет этот путь не попадает.
  • Не добавляй кастомные middleware на /metrics — не логируй его в access-лог (иначе логи будут забиты scrape-запросами).

/v1/* vs /internal/*

Сервис выставляет два набора endpoint'ов:

  • /v1/* — публичные. Приходят через API Gateway, у них есть JWT- авторизация (подписанные headers от gateway) и rate-limit.
  • /internal/* — сервис-к-сервису. Защищены отдельным токеном:
r.Route("/internal", func(r chi.Router) {
    r.Use(middleware.InternalToken(cfg.Internal.APIToken))
    r.Get("/reviews/{id}", h.Internal.GetReview)
})

INTERNAL_API_TOKEN — единый для всех /internal/* endpoint'ов одного сервиса. Сравнение через subtle.ConstantTimeCompare, чтобы не протекать по тайм-сайду. Плюс на уровне Kubernetes /internal/* закрыт NetworkPolicy: трафик разрешён только от других pod'ов кластера, извне — нет.

Никаких «полупубличных» путей (/v1/internal/..., /admin/internal/...) — два строго разделённых пространства имён.

Что не делать

  • Не возвращай «голый» payload ({"id":1} без оборачивания в data). Это ломает единый формат и мешает добавлять в ответ метаинформацию (cursor, warnings).
  • Не пиши в handler'е return после writeError, забыв про return — оба вызова выполнятся, в ответ уйдут два JSON-а подряд.
  • Не делай offset-пагинацию на публичных list-endpoint'ах. OFFSET 100000 сканирует 100000 строк на каждый запрос.
  • Не пиши status code прямо в handler (w.WriteHeader(404)). Всегда через mapServiceError / writeError.
  • Не читай r.Header.Get("X-Forwarded-For") напрямую в handler'е — это делает RealIP middleware. Адрес клиента достаёшь через r.RemoteAddr после middleware.

См. также