From 1be01ef4381520dc93a9195cc8967d18b5a815a9 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 16:27:59 -0700 Subject: [PATCH] Add httpserver package: TLS HTTP server with chi - Server wrapping chi.Mux + http.Server with TLS 1.3 minimum - ListenAndServeTLS and graceful Shutdown - LoggingMiddleware (method, path, status, duration, remote) - StatusWriter for status code capture in middleware - WriteJSON and WriteError helpers - 8 tests Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 1 + go.sum | 2 + httpserver/server.go | 121 ++++++++++++++++++++++++++++++ httpserver/server_test.go | 151 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 httpserver/server.go create mode 100644 httpserver/server_test.go diff --git a/go.mod b/go.mod index 686e1e2..6eca3c6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.wntrmute.dev/kyle/mcdsl go 1.25.7 require ( + github.com/go-chi/chi/v5 v5.2.5 github.com/pelletier/go-toml/v2 v2.3.0 modernc.org/sqlite v1.47.0 ) diff --git a/go.sum b/go.sum index 7256001..92ddb1f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/httpserver/server.go b/httpserver/server.go new file mode 100644 index 0000000..ead132e --- /dev/null +++ b/httpserver/server.go @@ -0,0 +1,121 @@ +// Package httpserver provides TLS HTTP server setup with chi, standard +// middleware, and graceful shutdown for Metacircular services. +package httpserver + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/mcdsl/config" +) + +// Server wraps a chi router and an http.Server with the standard +// Metacircular TLS configuration. +type Server struct { + // Router is the chi router. Services register their routes on it. + Router *chi.Mux + + // Logger is used by the logging middleware. + Logger *slog.Logger + + httpSrv *http.Server + cfg config.ServerConfig +} + +// New creates a Server configured from cfg. The underlying http.Server +// is configured with TLS 1.3 minimum and timeouts from the config. +// Services access s.Router to register routes before calling +// ListenAndServeTLS. +func New(cfg config.ServerConfig, logger *slog.Logger) *Server { + r := chi.NewRouter() + + s := &Server{ + Router: r, + Logger: logger, + cfg: cfg, + } + + s.httpSrv = &http.Server{ + Addr: cfg.ListenAddr, + Handler: r, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + ReadTimeout: cfg.ReadTimeout.Duration, + WriteTimeout: cfg.WriteTimeout.Duration, + IdleTimeout: cfg.IdleTimeout.Duration, + } + + return s +} + +// ListenAndServeTLS starts the HTTPS server using the TLS certificate and +// key from the config. It blocks until the server is shut down. Returns +// nil if the server was shut down gracefully via [Server.Shutdown]. +func (s *Server) ListenAndServeTLS() error { + s.Logger.Info("starting server", "addr", s.cfg.ListenAddr) + err := s.httpSrv.ListenAndServeTLS(s.cfg.TLSCert, s.cfg.TLSKey) + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf("httpserver: %w", err) + } + return nil +} + +// Shutdown gracefully shuts down the server, waiting for in-flight +// requests to complete. The provided context controls the shutdown +// timeout. +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpSrv.Shutdown(ctx) +} + +// LoggingMiddleware logs each HTTP request after it completes, including +// method, path, status code, duration, and remote address. +func (s *Server) LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &StatusWriter{ResponseWriter: w, Status: http.StatusOK} + next.ServeHTTP(sw, r) + s.Logger.Info("http", + "method", r.Method, + "path", r.URL.Path, + "status", sw.Status, + "duration", time.Since(start), + "remote", r.RemoteAddr, + ) + }) +} + +// StatusWriter wraps an http.ResponseWriter to capture the status code. +// It is exported for use in custom middleware. +type StatusWriter struct { + http.ResponseWriter + // Status is the HTTP status code written to the response. + Status int +} + +// WriteHeader captures the status code and delegates to the underlying +// ResponseWriter. +func (w *StatusWriter) WriteHeader(code int) { + w.Status = code + w.ResponseWriter.WriteHeader(code) +} + +// WriteJSON writes v as JSON with the given HTTP status code. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// WriteError writes a standard Metacircular error response: +// {"error": "message"}. +func WriteError(w http.ResponseWriter, status int, message string) { + WriteJSON(w, status, map[string]string{"error": message}) +} diff --git a/httpserver/server_test.go b/httpserver/server_test.go new file mode 100644 index 0000000..ad38385 --- /dev/null +++ b/httpserver/server_test.go @@ -0,0 +1,151 @@ +package httpserver + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.wntrmute.dev/kyle/mcdsl/config" +) + +func testConfig() config.ServerConfig { + return config.ServerConfig{ + ListenAddr: ":0", + TLSCert: "/tmp/cert.pem", + TLSKey: "/tmp/key.pem", + ReadTimeout: config.Duration{Duration: 30 * time.Second}, + WriteTimeout: config.Duration{Duration: 30 * time.Second}, + IdleTimeout: config.Duration{Duration: 120 * time.Second}, + ShutdownTimeout: config.Duration{Duration: 60 * time.Second}, + } +} + +func TestNew(t *testing.T) { + srv := New(testConfig(), slog.Default()) + if srv.Router == nil { + t.Fatal("Router is nil") + } + if srv.Logger == nil { + t.Fatal("Logger is nil") + } +} + +func TestLoggingMiddleware(t *testing.T) { + srv := New(testConfig(), slog.Default()) + + handler := srv.LoggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } +} + +func TestLoggingMiddlewareDefaultStatus(t *testing.T) { + srv := New(testConfig(), slog.Default()) + + // Handler that writes body without explicit WriteHeader. + handler := srv.LoggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestStatusWriter(t *testing.T) { + rec := httptest.NewRecorder() + sw := &StatusWriter{ResponseWriter: rec, Status: http.StatusOK} + + sw.WriteHeader(http.StatusNotFound) + + if sw.Status != http.StatusNotFound { + t.Fatalf("Status = %d, want %d", sw.Status, http.StatusNotFound) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("recorder Code = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestWriteJSON(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusOK, map[string]string{"key": "value"}) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want %q", ct, "application/json") + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["key"] != "value" { + t.Fatalf("body[key] = %q, want %q", body["key"], "value") + } +} + +func TestWriteError(t *testing.T) { + rec := httptest.NewRecorder() + WriteError(rec, http.StatusBadRequest, "something went wrong") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["error"] != "something went wrong" { + t.Fatalf("body[error] = %q, want %q", body["error"], "something went wrong") + } +} + +func TestShutdown(t *testing.T) { + srv := New(testConfig(), slog.Default()) + // Shutdown without starting should not panic. + if err := srv.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown: %v", err) + } +} + +func TestRouterIntegration(t *testing.T) { + srv := New(testConfig(), slog.Default()) + srv.Router.Use(srv.LoggingMiddleware) + srv.Router.Get("/health", func(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + srv.Router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("status = %q, want %q", body["status"], "ok") + } +}