Files
healthlog/internal/httpapi/httpapi.go
T
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

205 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package httpapi — HTTP-транспорт healthlog: приём пакетов и (позже) read API.
//
// Транспорт тонкий: разбирает запрос, зовёт use-case, переводит его ошибку в
// ответ. Исход операции логирует use-case, а не транспорт.
package httpapi
import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/points"
)
// Options — зависимости и настройки транспорта.
type Options struct {
Ingest *ingest.Service
Catalog *catalog.Service
Points *points.Service
Log *slog.Logger
WriteTokens []string
ReadTokens []string
MaxBodyMB int
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
// сервера», и полагаться на него нельзя, см. handleIngest.
IngestWriteBudget time.Duration
}
type api struct {
ingest *ingest.Service
catalog *catalog.Service
points *points.Service
log *slog.Logger
writeTokens []string
readTokens []string
maxBody int64
ingestBudget time.Duration
}
// New собирает HTTP-роутер.
func New(o Options) http.Handler {
a := &api{
ingest: o.Ingest,
catalog: o.Catalog,
points: o.Points,
log: o.Log,
writeTokens: o.WriteTokens,
readTokens: o.ReadTokens,
maxBody: int64(o.MaxBodyMB) << 20,
ingestBudget: o.IngestWriteBudget,
}
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(a.accessLog)
r.Get("/healthz", a.handleHealthz)
r.Route("/api/v1", func(r chi.Router) {
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
// Маршрут точек стоит РЯДОМ с каталогом, а не поверх него: у chi
// литеральный `/metrics` и шаблон `/metrics/{metric}` — разные узлы, и
// каталог остаётся достижим. Утверждается это тестом, а не верой.
r.With(requireToken(a.readTokens)).Get("/metrics/{metric}", a.handlePoints)
})
return r
}
func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// requireToken проверяет токен контура. Пустой список токенов = проверка
// выключена: локальный запуск в доверенной сети. О выключенной проверке
// сервис предупреждает на старте.
//
// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
// одним полем и несла бы три решения сразу — сравнение за постоянное время,
// «пустой список = выключено» и текст 401; правка любого из них в одном месте
// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения
// данных о здоровье.
//
// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не
// открывает. Схема строгая — токеном считается только значение после `Bearer `.
func requireToken(tokens []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(tokens) == 0 {
next.ServeHTTP(w, r)
return
}
if !tokenAllowed(bearer(r), tokens) {
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
return
}
next.ServeHTTP(w, r)
})
}
}
func bearer(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
return h[len(prefix):]
}
return ""
}
// tokenAllowed сравнивает токен за постоянное время: побайтовое сравнение с
// ранним выходом утекает длину совпадающего префикса.
func tokenAllowed(got string, allowed []string) bool {
ok := false
for _, want := range allowed {
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
ok = true
}
}
return ok
}
// accessLog пишет одну запись на запрос. Рутинно-частые эндпоинты
// (healthcheck) — на DEBUG, чтобы не забивать аудит.
func (a *api) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if r.URL.Path == "/healthz" {
level = slog.LevelDebug
}
a.log.Log(r.Context(), level, "http request",
"transport", "http",
"http.method", r.Method,
"http.route", routePattern(r),
"http.status_code", ww.Status(),
"duration_ms", time.Since(start).Milliseconds())
})
}
func routePattern(r *http.Request) string {
if rctx := chi.RouteContext(r.Context()); rctx != nil {
if p := rctx.RoutePattern(); p != "" {
return p
}
}
return r.URL.Path
}
// writeJSON — единственный сериализатор тел ответа.
//
// Экранирование HTML ВЫКЛЮЧЕНО, и это не косметика. `encoding/json` по
// умолчанию превращает `&`, `<` и `>` в `\u0026`, `\u003c`, `\u003e`; на
// маршруте, отдающем дословно сохранённое содержимое точки, это прямо ломает
// обещание дословности — имя источника приходит с телефона пользовательской
// строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и
// обезвредило тем же способом (`store.encodePayload`).
//
// Правило общее для всех читающих маршрутов намеренно: механизм один, и
// решать его заново на каждом маршруте значило бы завести второй способ.
// Отказ записи ВОЗВРАЩАЕТСЯ, а не глушится: код ответа отдан до сериализации,
// поэтому оборванное на середине тело снаружи неотличимо от успеха, а
// `accessLog` честно напишет `200`. Кто из вызывающих обязан об этом сказать —
// решает он сам; глушить молча нельзя ни одному.
func writeJSON(w http.ResponseWriter, status int, v any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
// errorWire — форма провода тела отказа, общая для всех маршрутов.
//
// Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа
// такая же часть публичного контракта, как и успешный ответ, и клиент видит его
// чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а
// не решения, и переименование ключа `error` не увидел бы ни один сторож — ни
// обход графа типов (карта строк проходит как стандартный тип), ни байтовый
// литерал (тел отказа он не закреплял).
type errorWire struct {
Error string `json:"error"`
}
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
// имена колонок и форма запроса.
func writeError(w http.ResponseWriter, status int, msg string) {
// Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о
// таком обрыве было бы шумом.
_ = writeJSON(w, status, errorWire{Error: msg})
}