Migrate all log.Printf/log.Fatalf to log/slog with structured key-value attributes per bible go-logging contract. - Add withRequestID middleware: generates crypto/rand 8-byte hex ID per request, sets X-Request-ID response header, injects into context - withErrorLogging uses slog with request_id from context - writeError internal log calls migrated to slog.Error/slog.Warn - All handler log calls in api, ingest, history packages use slog - cmd/reanimator-api configures slog.NewTextHandler(os.Stdout) at startup - cmd/reanimator-migrate, cmd/reanimator-reset migrated to slog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
4.3 KiB
Go
172 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"reanimator/internal/history"
|
|
"reanimator/internal/ingest"
|
|
"reanimator/internal/repository/failures"
|
|
"reanimator/internal/repository/registry"
|
|
"reanimator/internal/repository/timeline"
|
|
)
|
|
|
|
type Server struct {
|
|
httpServer *http.Server
|
|
cancelBg context.CancelFunc
|
|
}
|
|
|
|
type statusCaptureResponseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *statusCaptureResponseWriter) WriteHeader(status int) {
|
|
w.status = status
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *statusCaptureResponseWriter) Write(b []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func generateRequestID() string {
|
|
b := make([]byte, 8)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func withRequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := generateRequestID()
|
|
w.Header().Set("X-Request-ID", id)
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDCtxKey, id)))
|
|
})
|
|
}
|
|
|
|
func withErrorLogging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sw := &statusCaptureResponseWriter{ResponseWriter: w}
|
|
start := time.Now()
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
slog.Error("http panic",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"remote", r.RemoteAddr,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"panic", rec,
|
|
"request_id", requestIDFromCtx(r.Context()),
|
|
)
|
|
if sw.status == 0 {
|
|
writeJSON(sw, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
|
}
|
|
return
|
|
}
|
|
status := sw.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
if status >= 400 {
|
|
slog.Warn("http response error",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"query", r.URL.RawQuery,
|
|
"status", status,
|
|
"remote", r.RemoteAddr,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"request_id", requestIDFromCtx(r.Context()),
|
|
)
|
|
}
|
|
}()
|
|
next.ServeHTTP(sw, r)
|
|
})
|
|
}
|
|
|
|
func NewServer(addr string, readTimeout, writeTimeout time.Duration, db *sql.DB) *Server {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", healthHandler)
|
|
|
|
cancelBg := func() {}
|
|
if db != nil {
|
|
bgCtx, cancel := context.WithCancel(context.Background())
|
|
cancelBg = cancel
|
|
failureRepo := failures.NewFailureRepository(db)
|
|
assetRepo := registry.NewAssetRepository(db)
|
|
componentRepo := registry.NewComponentRepository(db)
|
|
installationRepo := registry.NewInstallationRepository(db)
|
|
timelineRepo := timeline.NewEventRepository(db)
|
|
historySvc := history.NewService(db)
|
|
historySvc.StartWorker(bgCtx)
|
|
|
|
RegisterRegistryRoutes(mux, RegistryDependencies{
|
|
Assets: assetRepo,
|
|
Components: componentRepo,
|
|
History: historySvc,
|
|
})
|
|
RegisterHistoryRoutes(mux, HistoryDependencies{
|
|
Service: historySvc,
|
|
})
|
|
RegisterIngestRoutes(mux, IngestDependencies{
|
|
Service: ingest.NewService(db),
|
|
})
|
|
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
|
|
Assets: assetRepo,
|
|
Components: componentRepo,
|
|
Installations: installationRepo,
|
|
Timeline: timelineRepo,
|
|
History: historySvc,
|
|
})
|
|
RegisterFailureRoutes(mux, FailureDependencies{
|
|
Failures: failureRepo,
|
|
Assets: assetRepo,
|
|
Components: componentRepo,
|
|
Installations: installationRepo,
|
|
History: historySvc,
|
|
})
|
|
RegisterUIRoutes(mux, UIDependencies{
|
|
Assets: assetRepo,
|
|
Components: componentRepo,
|
|
Installations: installationRepo,
|
|
Timeline: timelineRepo,
|
|
Failures: failureRepo,
|
|
})
|
|
}
|
|
|
|
return &Server{
|
|
httpServer: &http.Server{
|
|
Addr: addr,
|
|
Handler: withErrorLogging(withRequestID(mux)),
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
},
|
|
cancelBg: cancelBg,
|
|
}
|
|
}
|
|
|
|
func (s *Server) Start() error {
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if s.cancelBg != nil {
|
|
s.cancelBg()
|
|
}
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
func healthHandler(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|