- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
205 lines
9.0 KiB
Go
205 lines
9.0 KiB
Go
// 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})
|
||
}
|