// Package httpserver hosts the broker's HTTP surface. In phase 1 that's just // /healthz; OAuth endpoints and the gated MCP endpoint land in later phases. // // The package owns an *http.Server and its lifecycle. Signal handling lives // in main: the caller passes a context that is canceled on SIGTERM/SIGINT, // and Run initiates a graceful shutdown with a bounded deadline. package httpserver import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "time" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo" ) // DefaultShutdownTimeout is the graceful-shutdown deadline used when // Server.ShutdownTimeout is zero. const DefaultShutdownTimeout = 10 * time.Second // Pinger reports whether a dependency is still reachable. The store // implements this; other backends can too. type Pinger interface { Ping(ctx context.Context) error } // Server is the broker's HTTP front end. It composes a few well-known // handlers (/healthz today; OAuth and MCP in later phases) with an optional // ExtraHandler for routes the server doesn't own natively. type Server struct { // Addr is the TCP listen address (e.g. ":8080"). Required. Addr string // Log is the structured logger used for lifecycle and request events. // Must not be nil. Log *slog.Logger // Store is probed by /healthz. nil means "not configured" (health still // reports 200 but marks store as unconfigured). Store Pinger // ExtraHandler, if non-nil, receives any request /healthz does not match. // This is the extension point later phases use to add OAuth and MCP // routes without forking the server. ExtraHandler http.Handler // ShutdownTimeout bounds how long Run will wait for in-flight requests // during graceful shutdown. Zero means DefaultShutdownTimeout. ShutdownTimeout time.Duration } // Handler returns the composed HTTP handler without any listening setup. // Useful for handler-level tests via httptest.NewRecorder. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealth) if s.ExtraHandler != nil { // ServeMux treats "/" as the catch-all pattern; specific patterns // (like "GET /healthz") take precedence in Go 1.22+ routing. mux.Handle("/", s.ExtraHandler) } return mux } // Run starts the HTTP server and blocks until ctx is canceled or the server // stops on its own. On ctx cancellation, initiates graceful shutdown with // ShutdownTimeout as the deadline. func (s *Server) Run(ctx context.Context) error { if s.Log == nil { return errors.New("httpserver: Log is required") } if s.Addr == "" { return errors.New("httpserver: Addr is required") } srv := &http.Server{ Addr: s.Addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, } serveErr := make(chan error, 1) go func() { s.Log.Info("server listening", slog.String("addr", s.Addr)) err := srv.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { err = nil } serveErr <- err }() select { case err := <-serveErr: return err case <-ctx.Done(): s.Log.Info("shutdown initiated", slog.String("cause", ctx.Err().Error())) } timeout := s.ShutdownTimeout if timeout <= 0 { timeout = DefaultShutdownTimeout } shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() shutdownErr := srv.Shutdown(shutdownCtx) if shutdownErr != nil { // Graceful shutdown timed out. http.Server.Shutdown does not // interrupt active connections on its own — Close forces the // sockets closed, which cancels each in-flight request's context // and lets handlers observe the termination via r.Context().Done(). s.Log.Error("graceful shutdown exceeded deadline; forcing close", slog.Duration("deadline", timeout), slog.String("err", shutdownErr.Error())) _ = srv.Close() } // Wait for ListenAndServe's goroutine to exit so we don't leak it. <-serveErr if shutdownErr != nil { return fmt.Errorf("shutdown: %w", shutdownErr) } s.Log.Info("server stopped") return nil } type healthResponse struct { Status string `json:"status"` Version string `json:"version"` GitRevision string `json:"git_revision"` BuildDate string `json:"build_date"` Store string `json:"store"` } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := healthResponse{ Status: "ok", Version: buildinfo.Version, GitRevision: buildinfo.GitRevision, BuildDate: buildinfo.BuildDate, Store: "not configured", } status := http.StatusOK if s.Store != nil { pingCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := s.Store.Ping(pingCtx); err != nil { resp.Status = "degraded" resp.Store = "error: " + err.Error() status = http.StatusServiceUnavailable } else { resp.Store = "ok" } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) }