// 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/ingest" ) // Options — зависимости и настройки транспорта. type Options struct { Ingest *ingest.Service Log *slog.Logger WriteTokens []string MaxBodyMB int } type api struct { ingest *ingest.Service log *slog.Logger writeTokens []string maxBody int64 } // New собирает HTTP-роутер. func New(o Options) http.Handler { a := &api{ ingest: o.Ingest, log: o.Log, writeTokens: o.WriteTokens, maxBody: int64(o.MaxBodyMB) << 20, } 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(a.requireWriteToken).Post("/ingest", a.handleIngest) }) 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"}`)) } // requireWriteToken проверяет токен приёма. Пустой список токенов = проверка // выключена: локальный запуск в доверенной сети. О выключенной проверке // сервис предупреждает на старте. func (a *api) requireWriteToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(a.writeTokens) == 0 { next.ServeHTTP(w, r) return } if !tokenAllowed(bearer(r), a.writeTokens) { 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 } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }