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>
235 lines
6.3 KiB
Go
235 lines
6.3 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|