diff --git a/go.mod b/go.mod index 7b01a70..7fc3645 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/gin-contrib/cors v1.7.6 + github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/secure v1.1.2 github.com/gin-contrib/slog v1.2.0 github.com/gin-contrib/timeout v1.1.0 diff --git a/go.sum b/go.sum index 1ce9a76..ce67e61 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= -github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= +github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U= github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w= github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk= diff --git a/gzip_test.go b/gzip_test.go new file mode 100644 index 0000000..80093d1 --- /dev/null +++ b/gzip_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "compress/gzip" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── WithGzip ────────────────────────────────────────────────────────── + +func TestWithGzip_Good_CompressesResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_NoCompressionWithoutAcceptHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + // Deliberately not setting Accept-Encoding header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce == "gzip" { + t.Fatal("expected no gzip Content-Encoding when client does not request it") + } +} + +func TestWithGzip_Good_DefaultLevel(t *testing.T) { + // Calling WithGzip() with no arguments should use default compression + // and not panic. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q with default level, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_CustomLevel(t *testing.T) { + // WithGzip(gzip.BestSpeed) should work without panicking and still compress. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip(gzip.BestSpeed)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithGzip(), + api.WithRequestID(), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both gzip compression and request ID should be present. + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce) + } + + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} diff --git a/options.go b/options.go index e8e18d3..e0c4c66 100644 --- a/options.go +++ b/options.go @@ -3,11 +3,13 @@ package api import ( + "compress/gzip" "log/slog" "net/http" "time" "github.com/gin-contrib/cors" + gingzip "github.com/gin-contrib/gzip" "github.com/gin-contrib/secure" ginslog "github.com/gin-contrib/slog" "github.com/gin-contrib/timeout" @@ -120,6 +122,19 @@ func WithSecure() Option { } } +// WithGzip adds gzip response compression middleware via gin-contrib/gzip. +// An optional compression level may be supplied (e.g. gzip.BestSpeed, +// gzip.BestCompression). If omitted, gzip.DefaultCompression is used. +func WithGzip(level ...int) Option { + return func(e *Engine) { + l := gzip.DefaultCompression + if len(level) > 0 { + l = level[0] + } + e.middlewares = append(e.middlewares, gingzip.Gzip(l)) + } +} + // WithSlog adds structured request logging middleware via gin-contrib/slog. // Each request is logged with method, path, status code, latency, and client IP. // If logger is nil, slog.Default() is used.