summaryrefslogtreecommitdiffstats
path: root/http
diff options
context:
space:
mode:
Diffstat (limited to 'http')
-rw-r--r--http/server.go146
-rw-r--r--http/server_test.go159
2 files changed, 305 insertions, 0 deletions
diff --git a/http/server.go b/http/server.go
new file mode 100644
index 0000000..e13bc67
--- /dev/null
+++ b/http/server.go
@@ -0,0 +1,146 @@
+// Package http provides utilities for managing HTTP servers and listeners.
+//
+// It supports opening multiple network listeners from configuration,
+// running multiple HTTP servers concurrently with graceful shutdown,
+// and integrates structured logging with context.
+//
+// This package is designed to be used with context cancellation for concurrency control.
+
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+
+ "go.sudomsg.com/kit/logging"
+ "golang.org/x/sync/errgroup"
+)
+
+// Listeners is a slice of net.Listener interfaces representing multiple network listeners.
+type Listeners []net.Listener
+
+// CloseAll closes all listeners in the slice.
+// It aggregates all errors returned by individual Close calls using errors.Join.
+//
+// If no errors occur, it returns nil.
+func (ls Listeners) CloseAll() error {
+ var errs []error
+ for _, l := range ls {
+ if err := l.Close(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+ return nil
+}
+
+// ServerConfig defines a single network listener configuration.
+//
+// Network is the network type, e.g., "tcp".
+// Address is the socket address, e.g., ":8080".
+type ServerConfig struct {
+ Network string `toml:"network"`
+ Address string `toml:"network"`
+}
+
+// OpenConfigListeners opens network listeners as specified by the provided ServerConfig slice.
+//
+// It attempts to open all listeners concurrently and returns them if all succeed.
+//
+// If any listener fails to open, it closes all previously opened listeners and returns an error.
+//
+// The context controls cancellation of the opening process.
+func OpenConfigListners(ctx context.Context, config []ServerConfig) (Listeners, error) {
+ lns := make(Listeners, 0, len(config))
+ var mu sync.Mutex
+ g, ctx := errgroup.WithContext(ctx)
+ var lc net.ListenConfig
+ for _, cfg := range config {
+ g.Go(func() error {
+ network := cfg.Network
+ if network == "" {
+ network = "tcp"
+ }
+ ln, err := lc.Listen(ctx, network, cfg.Address)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s %s: %w", network, cfg.Address, err)
+ }
+
+ mu.Lock()
+ lns = append(lns, ln)
+ mu.Unlock()
+ return nil
+ })
+ }
+ if err := g.Wait(); err != nil {
+ _ = lns.CloseAll()
+ return nil, err
+ }
+ return lns, nil
+}
+
+func OpenListeners(ctx context.Context, config []ServerConfig) (Listeners, error) {
+ return OpenConfigListners(ctx, config)
+}
+
+// RunHTTPServers runs HTTP servers concurrently on all provided listeners.
+//
+// The provided handler is used for all servers.
+// Servers respond to context cancellation by performing a graceful shutdown with a timeout of 10 seconds.
+//
+// Logging is performed using the slog.Logger extracted from context.
+// Each server logs startup, shutdown, and errors.
+//
+// This function blocks until all servers have stopped or an error occurs.
+func RunHTTPServers(ctx context.Context, lns Listeners, handler http.Handler) error {
+ g, ctx := errgroup.WithContext(ctx)
+
+ for _, ln := range lns {
+ g.Go(func() error {
+ logger, ctx := logging.With(ctx, "address", ln.Addr)
+
+ srv := &http.Server{
+ Addr: ln.Addr().String(),
+ Handler: handler,
+ BaseContext: func(l net.Listener) context.Context {
+ return ctx
+ },
+ ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
+ }
+
+ logger.Log(ctx, slog.LevelInfo, "HTTP server serving")
+
+ if err := httpServeContext(ctx, srv, ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return fmt.Errorf("HTTP server Serve Error: %w", err)
+ }
+ return nil
+ })
+
+ }
+ return g.Wait()
+}
+
+func httpServeContext(ctx context.Context, srv *http.Server, ln net.Listener) error {
+ logger := logging.FromContext(ctx)
+ go func() {
+ <-ctx.Done()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ err := srv.Shutdown(ctx)
+ if err != nil {
+ logger.Log(ctx, slog.LevelWarn, "HTTP server Shutdown Error", slog.Any("error", err))
+ } else {
+ logger.Log(ctx, slog.LevelInfo, "HTTP Server Shutdown Complete")
+ }
+ }()
+ return srv.Serve(ln)
+}
diff --git a/http/server_test.go b/http/server_test.go
new file mode 100644
index 0000000..7138c73
--- /dev/null
+++ b/http/server_test.go
@@ -0,0 +1,159 @@
+package http_test
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ httpServer "go.sudomsg.com/kit/http"
+)
+
+// helper to find free TCP ports
+func newListener(tb testing.TB) net.Listener {
+ tb.Helper()
+
+ ln, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ tb.Fatalf("failed to open listener: %v", err)
+ }
+ return ln
+}
+
+func TestOpenConfigListeners(t *testing.T) {
+ t.Parallel()
+ t.Run("successful config", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ cfg := []httpServer.ServerConfig{
+ {Network: "tcp", Address: "localhost:0"},
+ {Network: "tcp", Address: "localhost:0"},
+ }
+
+ lns, err := httpServer.OpenConfigListners(ctx, cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer lns.CloseAll()
+
+ if got := len(lns); got != len(cfg) {
+ t.Errorf("expected %d listeners, got %d", len(cfg), got)
+ }
+ })
+
+ t.Run("port conflict triggers cleanup", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ conflict := newListener(t)
+ defer conflict.Close()
+
+ cfg := []httpServer.ServerConfig{
+ {Network: "tcp", Address: "localhost:0"},
+ {Network: "tcp", Address: conflict.Addr().String()}, // will fail
+ }
+
+ lns, err := httpServer.OpenConfigListners(ctx, cfg)
+ if err == nil {
+ defer lns.CloseAll()
+ t.Fatal("expected error due to conflict, got nil")
+ }
+ })
+}
+
+func TestCloseAll(t *testing.T) {
+ t.Run("closes all listeners", func(t *testing.T) {
+ t.Parallel()
+ ln1 := newListener(t)
+ ln2 := newListener(t)
+
+ ls := httpServer.Listeners{ln1, ln2}
+ err := ls.CloseAll()
+ if err != nil {
+ t.Errorf("unexpected error from CloseAll: %v", err)
+ }
+
+ for _, ln := range ls {
+ if _, err := ln.Accept(); err == nil {
+ t.Error("expected listener to be closed, but Accept succeeded")
+ }
+ }
+ })
+}
+
+func TestRunHTTPServers(t *testing.T) {
+ t.Parallel()
+ t.Run("basic serve and shutdown", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "hello")
+ })
+
+ ln := newListener(t)
+ addr := ln.Addr().String()
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ go func() {
+ // Wait for server to be ready
+ time.Sleep(200 * time.Millisecond)
+
+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr, nil)
+ resp, err := http.DefaultClient.Do(r)
+ if err != nil {
+ t.Errorf("http.Do error: %v", err)
+ return
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if got := strings.TrimSpace(string(body)); got != "hello" {
+ t.Errorf("unexpected response body: %q", got)
+ }
+
+ cancel() // shutdown the server
+ }()
+
+ err := httpServer.RunHTTPServers(ctx, httpServer.Listeners{ln}, handler)
+ if err != nil {
+ t.Fatalf("RunHTTPServers failed: %v", err)
+ }
+ })
+}
+
+func TestInvalidConfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ config httpServer.ServerConfig
+ }{
+ {
+ name: "invalid network",
+ config: httpServer.ServerConfig{Network: "invalid", Address: "localhost:0"},
+ },
+ {
+ name: "invalid address",
+ config: httpServer.ServerConfig{Network: "tcp", Address: "::::"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+
+ _, err := httpServer.OpenConfigListners(ctx, []httpServer.ServerConfig{tt.config})
+ if err == nil {
+ t.Fatal("OpenConfigListners() expected error, got nil")
+ }
+ })
+ }
+}