Adds WithSlog(logger) option wrapping gin-contrib/slog for structured request logging via Go's standard log/slog package. Logs method, path, status code, latency, and client IP for every request. Falls back to slog.Default() when nil is passed. Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
167 lines
4.1 KiB
Go
167 lines
4.1 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
api "forge.lthn.ai/core/go-api"
|
|
)
|
|
|
|
// ── WithSlog ──────────────────────────────────────────────────────────
|
|
|
|
func TestWithSlog_Good_LogsRequestFields(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
|
|
e, _ := api.New(api.WithSlog(logger))
|
|
e.Register(&stubGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
output := buf.String()
|
|
if output == "" {
|
|
t.Fatal("expected slog output, got empty string")
|
|
}
|
|
|
|
// The structured log should contain request fields.
|
|
for _, field := range []string{"status", "method", "path", "latency", "ip"} {
|
|
if !bytes.Contains(buf.Bytes(), []byte(field)) {
|
|
t.Errorf("expected log output to contain field %q, got: %s", field, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWithSlog_Good_NilLoggerUsesDefault(t *testing.T) {
|
|
// Passing nil should not panic; it uses slog.Default().
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
e, _ := api.New(api.WithSlog(nil))
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestWithSlog_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
|
|
|
e, _ := api.New(
|
|
api.WithSlog(logger),
|
|
api.WithRequestID(),
|
|
)
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
// Both slog output and request ID header should be present.
|
|
if buf.Len() == 0 {
|
|
t.Fatal("expected slog output from WithSlog")
|
|
}
|
|
if w.Header().Get("X-Request-ID") == "" {
|
|
t.Fatal("expected X-Request-ID header from WithRequestID")
|
|
}
|
|
}
|
|
|
|
func TestWithSlog_Good_Logs404Status(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
|
|
|
e, _ := api.New(api.WithSlog(logger))
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", w.Code)
|
|
}
|
|
|
|
output := buf.String()
|
|
if output == "" {
|
|
t.Fatal("expected slog output for 404 request")
|
|
}
|
|
|
|
// Should contain the 404 status.
|
|
if !bytes.Contains(buf.Bytes(), []byte("404")) {
|
|
t.Errorf("expected log to contain status 404, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestWithSlog_Bad_LogsMethodAndPath(t *testing.T) {
|
|
// Verifies POST method and custom path appear in log output.
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
|
|
|
e, _ := api.New(api.WithSlog(logger))
|
|
e.Register(&stubGroup{})
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodPost, "/stub/ping", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
output := buf.String()
|
|
if !bytes.Contains(buf.Bytes(), []byte("POST")) {
|
|
t.Errorf("expected log to contain method POST, got: %s", output)
|
|
}
|
|
if !bytes.Contains(buf.Bytes(), []byte("/stub/ping")) {
|
|
t.Errorf("expected log to contain path /stub/ping, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestWithSlog_Ugly_DoubleSlogDoesNotPanic(t *testing.T) {
|
|
// Applying WithSlog twice should not panic.
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
var buf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
|
|
|
e, _ := api.New(
|
|
api.WithSlog(logger),
|
|
api.WithSlog(logger),
|
|
)
|
|
|
|
h := e.Handler()
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|