Skip to Content
ConventionsHTTP API

HTTP API

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

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

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).
  • 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 из serviceHTTPcode
ErrNotFound404not_found
ErrValidation400invalid_input
ErrUnauthorized401unauthorized
ErrForbidden403forbidden
ErrConflict409conflict
ErrRateLimit429rate_limited
default500internal

Никаких 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.

Пагинация

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-basedOffset-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 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.

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

Сортировка

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.

См. также

Last updated on