Add production-ready HTTP server package with routing, health checks, and middleware
Introduces server/ sub-package as the server-side companion to the existing Client. Includes Router (over http.ServeMux with groups and mounting), graceful shutdown with signal handling, health endpoints (/healthz, /readyz), and built-in middlewares (RequestID, Recovery, Logging). Zero external dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
234
server/middleware_test.go
Normal file
234
server/middleware_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.codelab.vc/pkg/httpx/server"
|
||||
)
|
||||
|
||||
func TestChain(t *testing.T) {
|
||||
t.Run("applies middlewares in correct order", func(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
mwA := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
order = append(order, "A-before")
|
||||
next.ServeHTTP(w, r)
|
||||
order = append(order, "A-after")
|
||||
})
|
||||
}
|
||||
|
||||
mwB := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
order = append(order, "B-before")
|
||||
next.ServeHTTP(w, r)
|
||||
order = append(order, "B-after")
|
||||
})
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
order = append(order, "handler")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
chained := server.Chain(mwA, mwB)(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
chained.ServeHTTP(w, req)
|
||||
|
||||
expected := []string{"A-before", "B-before", "handler", "B-after", "A-after"}
|
||||
if len(order) != len(expected) {
|
||||
t.Fatalf("got %v, want %v", order, expected)
|
||||
}
|
||||
for i, v := range expected {
|
||||
if order[i] != v {
|
||||
t.Fatalf("order[%d] = %q, want %q", i, order[i], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty chain returns handler unchanged", func(t *testing.T) {
|
||||
called := false
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
chained := server.Chain()(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
chained.ServeHTTP(w, req)
|
||||
|
||||
if !called {
|
||||
t.Fatal("handler was not called")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestID(t *testing.T) {
|
||||
t.Run("generates ID when not present", func(t *testing.T) {
|
||||
var gotID string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotID = server.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mw := server.RequestID()(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
if gotID == "" {
|
||||
t.Fatal("expected request ID in context, got empty")
|
||||
}
|
||||
if w.Header().Get("X-Request-Id") != gotID {
|
||||
t.Fatalf("response header %q != context ID %q", w.Header().Get("X-Request-Id"), gotID)
|
||||
}
|
||||
// UUID v4 format: 8-4-4-4-12 hex chars.
|
||||
if len(gotID) != 36 {
|
||||
t.Fatalf("expected UUID length 36, got %d: %q", len(gotID), gotID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing ID", func(t *testing.T) {
|
||||
var gotID string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotID = server.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mw := server.RequestID()(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Request-Id", "custom-123")
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
if gotID != "custom-123" {
|
||||
t.Fatalf("got ID %q, want %q", gotID, "custom-123")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("context without ID returns empty", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if id := server.RequestIDFromContext(req.Context()); id != "" {
|
||||
t.Fatalf("expected empty, got %q", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecovery(t *testing.T) {
|
||||
t.Run("recovers from panic and returns 500", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
panic("something went wrong")
|
||||
})
|
||||
|
||||
mw := server.Recovery()(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("got status %d, want %d", w.Code, http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logs panic with logger", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
panic("boom")
|
||||
})
|
||||
|
||||
mw := server.Recovery(server.WithRecoveryLogger(logger))(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
if !strings.Contains(buf.String(), "panic recovered") {
|
||||
t.Fatalf("expected log to contain 'panic recovered', got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "boom") {
|
||||
t.Fatalf("expected log to contain 'boom', got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes through without panic", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mw := server.Recovery()(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogging(t *testing.T) {
|
||||
t.Run("logs request details", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
mw := server.Logging(logger)(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
logOutput := buf.String()
|
||||
if !strings.Contains(logOutput, "request completed") {
|
||||
t.Fatalf("expected 'request completed' in log, got %q", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, "POST") {
|
||||
t.Fatalf("expected method in log, got %q", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, "/api/users") {
|
||||
t.Fatalf("expected path in log, got %q", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, "status=201") {
|
||||
t.Fatalf("expected status=201 in log, got %q", logOutput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logs error level for 5xx", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
})
|
||||
|
||||
mw := server.Logging(logger)(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
mw.ServeHTTP(w, req)
|
||||
|
||||
logOutput := buf.String()
|
||||
if !strings.Contains(logOutput, "level=ERROR") {
|
||||
t.Fatalf("expected ERROR level in log, got %q", logOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user