├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── goreleaser.yml │ ├── trivy-scan.yml │ ├── codeql.yml │ └── go.yml ├── .goreleaser.yaml ├── .golangci.yml ├── _example ├── main.go ├── go.mod └── go.sum ├── LICENSE ├── go.mod ├── handler.go ├── handler_test.go ├── README.md ├── gzip.go ├── static_test.go ├── go.sum ├── options.go └── gzip_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.out 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - # If true, skip the build. 3 | # Useful for library projects. 4 | # Default is false 5 | skip: true 6 | 7 | changelog: 8 | use: github 9 | groups: 10 | - title: Features 11 | regexp: "^.*feat[(\\w)]*:+.*$" 12 | order: 0 13 | - title: "Bug fixes" 14 | regexp: "^.*fix[(\\w)]*:+.*$" 15 | order: 1 16 | - title: "Enhancements" 17 | regexp: "^.*chore[(\\w)]*:+.*$" 18 | order: 2 19 | - title: "Refactor" 20 | regexp: "^.*refactor[(\\w)]*:+.*$" 21 | order: 3 22 | - title: "Build process updates" 23 | regexp: ^.*?(build|ci)(\(.+\))??!?:.+$ 24 | order: 4 25 | - title: "Documentation updates" 26 | regexp: ^.*?docs?(\(.+\))??!?:.+$ 27 | order: 4 28 | - title: Others 29 | order: 999 30 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: Goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version-file: go.mod 24 | check-latest: true 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | # either 'goreleaser' (default) or 'goreleaser-pro' 29 | distribution: goreleaser 30 | version: latest 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - dupl 8 | - errcheck 9 | - exhaustive 10 | - gochecknoinits 11 | - goconst 12 | - gocritic 13 | - gocyclo 14 | - goprintffuncname 15 | - gosec 16 | - govet 17 | - ineffassign 18 | - lll 19 | - misspell 20 | - nakedret 21 | - noctx 22 | - nolintlint 23 | - rowserrcheck 24 | - staticcheck 25 | - unconvert 26 | - unparam 27 | - unused 28 | - whitespace 29 | exclusions: 30 | generated: lax 31 | presets: 32 | - comments 33 | - common-false-positives 34 | - legacy 35 | - std-error-handling 36 | paths: 37 | - third_party$ 38 | - builtin$ 39 | - examples$ 40 | formatters: 41 | enable: 42 | - gofmt 43 | - gofumpt 44 | - goimports 45 | exclusions: 46 | generated: lax 47 | paths: 48 | - third_party$ 49 | - builtin$ 50 | - examples$ 51 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-contrib/gzip" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func main() { 14 | r := gin.Default() 15 | r.Use( 16 | func(c *gin.Context) { 17 | c.Writer.Header().Add("Vary", "Origin") 18 | }, 19 | gzip.Gzip( 20 | gzip.DefaultCompression, 21 | gzip.WithExcludedPaths([]string{"/ping2"}), 22 | )) 23 | 24 | r.GET("/ping", func(c *gin.Context) { 25 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 26 | }) 27 | r.GET("/ping2", func(c *gin.Context) { 28 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 29 | }) 30 | r.GET("/stream", func(c *gin.Context) { 31 | c.Header("Content-Type", "text/event-stream") 32 | c.Header("Connection", "keep-alive") 33 | for i := 0; i < 10; i++ { 34 | fmt.Fprintf(c.Writer, "id: %d\ndata: tick %d\n\n", i, time.Now().Unix()) 35 | c.Writer.Flush() 36 | time.Sleep(1 * time.Second) 37 | } 38 | }) 39 | 40 | // Listen and Server in 0.0.0.0:8080 41 | if err := r.Run(":8080"); err != nil { 42 | log.Fatal(err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gin-Gonic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/trivy-scan.yml: -------------------------------------------------------------------------------- 1 | name: Trivy Security Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | # Run daily at 00:00 UTC 12 | - cron: '0 0 * * *' 13 | workflow_dispatch: # Allow manual trigger 14 | 15 | permissions: 16 | contents: read 17 | security-events: write # Required for uploading SARIF results 18 | 19 | jobs: 20 | trivy-scan: 21 | name: Trivy Security Scan 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v6 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Run Trivy vulnerability scanner (source code) 30 | uses: aquasecurity/trivy-action@0.33.1 31 | with: 32 | scan-type: 'fs' 33 | scan-ref: '.' 34 | scanners: 'vuln,secret,misconfig' 35 | format: 'sarif' 36 | output: 'trivy-results.sarif' 37 | severity: 'CRITICAL,HIGH,MEDIUM' 38 | ignore-unfixed: true 39 | 40 | - name: Upload Trivy results to GitHub Security tab 41 | uses: github/codeql-action/upload-sarif@v4 42 | if: always() 43 | with: 44 | sarif_file: 'trivy-results.sarif' 45 | 46 | - name: Run Trivy scanner (table output for logs) 47 | uses: aquasecurity/trivy-action@0.33.1 48 | if: always() 49 | with: 50 | scan-type: 'fs' 51 | scan-ref: '.' 52 | scanners: 'vuln,secret,misconfig' 53 | format: 'table' 54 | severity: 'CRITICAL,HIGH,MEDIUM' 55 | ignore-unfixed: true 56 | exit-code: '1' 57 | -------------------------------------------------------------------------------- /_example/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/gin-contrib/gzip v1.1.0 7 | github.com/gin-gonic/gin v1.11.0 8 | ) 9 | 10 | require ( 11 | github.com/bytedance/gopkg v0.1.3 // indirect 12 | github.com/bytedance/sonic v1.14.1 // indirect 13 | github.com/bytedance/sonic/loader v0.3.0 // indirect 14 | github.com/cloudwego/base64x v0.1.6 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 16 | github.com/gin-contrib/sse v1.1.0 // indirect 17 | github.com/go-playground/locales v0.14.1 // indirect 18 | github.com/go-playground/universal-translator v0.18.1 // indirect 19 | github.com/go-playground/validator/v10 v10.28.0 // indirect 20 | github.com/goccy/go-json v0.10.5 // indirect 21 | github.com/goccy/go-yaml v1.18.0 // indirect 22 | github.com/json-iterator/go v1.1.12 // indirect 23 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 24 | github.com/leodido/go-urn v1.4.0 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 27 | github.com/modern-go/reflect2 v1.0.2 // indirect 28 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 29 | github.com/quic-go/qpack v0.6.0 // indirect 30 | github.com/quic-go/quic-go v0.57.1 // indirect 31 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 32 | github.com/ugorji/go/codec v1.3.0 // indirect 33 | golang.org/x/arch v0.22.0 // indirect 34 | golang.org/x/crypto v0.45.0 // indirect 35 | golang.org/x/net v0.47.0 // indirect 36 | golang.org/x/sys v0.38.0 // indirect 37 | golang.org/x/text v0.31.0 // indirect 38 | google.golang.org/protobuf v1.36.10 // indirect 39 | ) 40 | 41 | replace github.com/gin-contrib/gzip => ../ 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gin-contrib/gzip 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.11.0 7 | github.com/stretchr/testify v1.11.1 8 | ) 9 | 10 | require ( 11 | github.com/bytedance/gopkg v0.1.3 // indirect 12 | github.com/bytedance/sonic v1.14.1 // indirect 13 | github.com/bytedance/sonic/loader v0.3.0 // indirect 14 | github.com/cloudwego/base64x v0.1.6 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 17 | github.com/gin-contrib/sse v1.1.0 // indirect 18 | github.com/go-playground/locales v0.14.1 // indirect 19 | github.com/go-playground/universal-translator v0.18.1 // indirect 20 | github.com/go-playground/validator/v10 v10.28.0 // indirect 21 | github.com/goccy/go-json v0.10.5 // indirect 22 | github.com/goccy/go-yaml v1.18.0 // indirect 23 | github.com/json-iterator/go v1.1.12 // indirect 24 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 25 | github.com/kr/text v0.2.0 // indirect 26 | github.com/leodido/go-urn v1.4.0 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 29 | github.com/modern-go/reflect2 v1.0.2 // indirect 30 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/quic-go/qpack v0.6.0 // indirect 33 | github.com/quic-go/quic-go v0.57.1 // indirect 34 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 35 | github.com/ugorji/go/codec v1.3.0 // indirect 36 | go.uber.org/mock v0.6.0 // indirect 37 | golang.org/x/arch v0.22.0 // indirect 38 | golang.org/x/crypto v0.45.0 // indirect 39 | golang.org/x/net v0.47.0 // indirect 40 | golang.org/x/sys v0.38.0 // indirect 41 | golang.org/x/text v0.31.0 // indirect 42 | google.golang.org/protobuf v1.36.10 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master] 20 | schedule: 21 | - cron: "41 23 * * 6" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v6 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v4 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v4 55 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version-file: go.mod 24 | check-latest: true 25 | - name: Setup golangci-lint 26 | uses: golangci/golangci-lint-action@v9 27 | with: 28 | version: v2.6 29 | args: --verbose 30 | test: 31 | strategy: 32 | matrix: 33 | os: [ubuntu-latest] 34 | go: [1.24, 1.25] 35 | include: 36 | - os: ubuntu-latest 37 | go-build: ~/.cache/go-build 38 | name: ${{ matrix.os }} @ Go ${{ matrix.go }} 39 | runs-on: ${{ matrix.os }} 40 | env: 41 | GO111MODULE: on 42 | GOPROXY: https://proxy.golang.org 43 | steps: 44 | - name: Set up Go ${{ matrix.go }} 45 | uses: actions/setup-go@v6 46 | with: 47 | go-version: ${{ matrix.go }} 48 | 49 | - name: Checkout Code 50 | uses: actions/checkout@v6 51 | with: 52 | ref: ${{ github.ref }} 53 | 54 | - uses: actions/cache@v5 55 | with: 56 | path: | 57 | ${{ matrix.go-build }} 58 | ~/go/pkg/mod 59 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 60 | restore-keys: | 61 | ${{ runner.os }}-go- 62 | - name: Run Tests 63 | run: | 64 | go test -v -covermode=atomic -coverprofile=coverage.out 65 | 66 | - name: Upload coverage to Codecov 67 | uses: codecov/codecov-action@v5 68 | with: 69 | flags: ${{ matrix.os }},go-${{ matrix.go }} 70 | 71 | vulnerability-scanning: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v6 75 | with: 76 | fetch-depth: 0 77 | 78 | - name: Run Trivy vulnerability scanner in repo mode 79 | uses: aquasecurity/trivy-action@0.33.1 80 | with: 81 | scan-type: "fs" 82 | ignore-unfixed: true 83 | format: "sarif" 84 | output: "trivy-results.sarif" 85 | exit-code: "1" 86 | severity: "CRITICAL,HIGH" 87 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | const ( 16 | headerAcceptEncoding = "Accept-Encoding" 17 | headerContentEncoding = "Content-Encoding" 18 | headerVary = "Vary" 19 | ) 20 | 21 | type gzipHandler struct { 22 | *config 23 | gzPool sync.Pool 24 | } 25 | 26 | func isCompressionLevelValid(level int) bool { 27 | return level == gzip.DefaultCompression || 28 | level == gzip.NoCompression || 29 | (level >= gzip.BestSpeed && level <= gzip.BestCompression) 30 | } 31 | 32 | func newGzipHandler(level int, opts ...Option) *gzipHandler { 33 | cfg := &config{ 34 | excludedExtensions: DefaultExcludedExtentions, 35 | } 36 | 37 | // Apply each option to the config 38 | for _, o := range opts { 39 | o.apply(cfg) 40 | } 41 | 42 | if !isCompressionLevelValid(level) { 43 | // For web content, level 4 seems to be a sweet spot. 44 | level = 4 45 | } 46 | 47 | handler := &gzipHandler{ 48 | config: cfg, 49 | gzPool: sync.Pool{ 50 | New: func() interface{} { 51 | gz, _ := gzip.NewWriterLevel(io.Discard, level) 52 | return gz 53 | }, 54 | }, 55 | } 56 | return handler 57 | } 58 | 59 | // Handle is a middleware function for handling gzip compression in HTTP requests and responses. 60 | // It first checks if the request has a "Content-Encoding" header set to "gzip" and if a decompression 61 | // function is provided, it will call the decompression function. If the handler is set to decompress only, 62 | // or if the custom compression decision function indicates not to compress, it will return early. 63 | // Otherwise, it retrieves a gzip.Writer from the pool, sets the necessary response headers for gzip encoding, 64 | // and wraps the response writer with a gzipWriter. After the request is processed, it ensures the gzip.Writer 65 | // is properly closed and the "Content-Length" header is set based on the response size. 66 | func (g *gzipHandler) Handle(c *gin.Context) { 67 | if fn := g.decompressFn; fn != nil && strings.Contains(c.Request.Header.Get("Content-Encoding"), "gzip") { 68 | fn(c) 69 | } 70 | 71 | if g.decompressOnly || 72 | (g.customShouldCompressFn != nil && !g.customShouldCompressFn(c)) || 73 | (g.customShouldCompressFn == nil && !g.shouldCompress(c.Request)) { 74 | return 75 | } 76 | 77 | gz := g.gzPool.Get().(*gzip.Writer) 78 | gz.Reset(c.Writer) 79 | 80 | c.Header(headerContentEncoding, "gzip") 81 | c.Writer.Header().Add(headerVary, headerAcceptEncoding) 82 | // check ETag Header 83 | originalEtag := c.GetHeader("ETag") 84 | if originalEtag != "" && !strings.HasPrefix(originalEtag, "W/") { 85 | c.Header("ETag", "W/"+originalEtag) 86 | } 87 | gw := &gzipWriter{ 88 | ResponseWriter: c.Writer, 89 | writer: gz, 90 | minLength: g.minLength, 91 | } 92 | c.Writer = gw 93 | defer func() { 94 | // Only close gzip writer if it was actually used (not for error responses) 95 | if gw.status >= 400 { 96 | // Remove gzip headers for error responses when handler is complete 97 | gw.removeGzipHeaders() 98 | gz.Reset(io.Discard) 99 | } else if !gw.shouldCompress { 100 | // if compression limit not met after all write commands were executed, then the response data is stored in the 101 | // internal buffer which should now be written to the response writer directly 102 | gw.Header().Del(headerContentEncoding) 103 | gw.Header().Del(headerVary) 104 | // must refer directly to embedded writer since c.Writer gets overridden 105 | _, _ = gw.ResponseWriter.Write(gw.buffer.Bytes()) 106 | gz.Reset(io.Discard) 107 | } else if c.Writer.Size() < 0 { 108 | // do not write gzip footer when nothing is written to the response body 109 | // Note: This is only executed when gw.minLength == 0 (ie always compress) 110 | gz.Reset(io.Discard) 111 | } 112 | _ = gz.Close() 113 | if c.Writer.Size() > -1 { 114 | c.Header("Content-Length", strconv.Itoa(c.Writer.Size())) 115 | } 116 | g.gzPool.Put(gz) 117 | }() 118 | c.Next() 119 | } 120 | 121 | func (g *gzipHandler) shouldCompress(req *http.Request) bool { 122 | if !strings.Contains(req.Header.Get(headerAcceptEncoding), "gzip") || 123 | strings.Contains(req.Header.Get("Connection"), "Upgrade") { 124 | return false 125 | } 126 | 127 | // Check if the request path is excluded from compression 128 | extension := filepath.Ext(req.URL.Path) 129 | if g.excludedExtensions.Contains(extension) || 130 | g.excludedPaths.Contains(req.URL.Path) || 131 | g.excludedPathesRegexs.Contains(req.URL.Path) { 132 | return false 133 | } 134 | 135 | return true 136 | } 137 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "context" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestHandleGzip(t *testing.T) { 17 | gin.SetMode(gin.TestMode) 18 | 19 | tests := []struct { 20 | name string 21 | path string 22 | acceptEncoding string 23 | expectedContentEncoding string 24 | expectedBody string 25 | }{ 26 | { 27 | name: "Gzip compression", 28 | path: "/", 29 | acceptEncoding: gzipEncoding, 30 | expectedContentEncoding: gzipEncoding, 31 | expectedBody: "Gzip Test Response", 32 | }, 33 | { 34 | name: "No compression", 35 | path: "/", 36 | acceptEncoding: "", 37 | expectedContentEncoding: "", 38 | expectedBody: "Gzip Test Response", 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | router := gin.New() 45 | router.Use(Gzip(DefaultCompression)) 46 | router.GET("/", func(c *gin.Context) { 47 | c.String(http.StatusOK, "Gzip Test Response") 48 | }) 49 | 50 | req, _ := http.NewRequestWithContext(context.Background(), "GET", tt.path, nil) 51 | req.Header.Set(headerAcceptEncoding, tt.acceptEncoding) 52 | 53 | w := httptest.NewRecorder() 54 | router.ServeHTTP(w, req) 55 | 56 | assert.Equal(t, http.StatusOK, w.Code) 57 | assert.Equal(t, tt.expectedContentEncoding, w.Header().Get("Content-Encoding")) 58 | 59 | if tt.expectedContentEncoding == gzipEncoding { 60 | gr, err := gzip.NewReader(w.Body) 61 | assert.NoError(t, err) 62 | defer gr.Close() 63 | 64 | body, _ := io.ReadAll(gr) 65 | assert.Equal(t, tt.expectedBody, string(body)) 66 | } else { 67 | assert.Equal(t, tt.expectedBody, w.Body.String()) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestHandleDecompressGzip(t *testing.T) { 74 | gin.SetMode(gin.TestMode) 75 | 76 | buf := &bytes.Buffer{} 77 | gz, _ := gzip.NewWriterLevel(buf, gzip.DefaultCompression) 78 | if _, err := gz.Write([]byte("Gzip Test Response")); err != nil { 79 | gz.Close() 80 | t.Fatal(err) 81 | } 82 | gz.Close() 83 | 84 | router := gin.New() 85 | router.Use(Gzip(DefaultCompression, WithDecompressFn(DefaultDecompressHandle))) 86 | router.POST("/", func(c *gin.Context) { 87 | data, err := c.GetRawData() 88 | assert.NoError(t, err) 89 | assert.Equal(t, "Gzip Test Response", string(data)) 90 | c.String(http.StatusOK, "ok") 91 | }) 92 | 93 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", buf) 94 | req.Header.Set("Content-Encoding", gzipEncoding) 95 | 96 | w := httptest.NewRecorder() 97 | router.ServeHTTP(w, req) 98 | 99 | assert.Equal(t, http.StatusOK, w.Code) 100 | assert.Equal(t, "ok", w.Body.String()) 101 | } 102 | 103 | func TestHandle404NoCompression(t *testing.T) { 104 | gin.SetMode(gin.TestMode) 105 | 106 | tests := []struct { 107 | name string 108 | acceptEncoding string 109 | expectedGzip bool 110 | }{ 111 | { 112 | name: "404 with gzip accept-encoding should not compress", 113 | acceptEncoding: gzipEncoding, 114 | expectedGzip: false, 115 | }, 116 | { 117 | name: "404 without gzip accept-encoding", 118 | acceptEncoding: "", 119 | expectedGzip: false, 120 | }, 121 | } 122 | 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | router := gin.New() 126 | router.Use(Gzip(DefaultCompression)) 127 | // Register a route to get proper 404 for unmatched paths 128 | router.NoRoute(func(c *gin.Context) { 129 | c.String(http.StatusNotFound, "404 page not found") 130 | }) 131 | 132 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/nonexistent", nil) 133 | if tt.acceptEncoding != "" { 134 | req.Header.Set(headerAcceptEncoding, tt.acceptEncoding) 135 | } 136 | 137 | w := httptest.NewRecorder() 138 | router.ServeHTTP(w, req) 139 | 140 | assert.Equal(t, http.StatusNotFound, w.Code) 141 | 142 | // Check that Content-Encoding header is not set for 404 responses 143 | contentEncoding := w.Header().Get("Content-Encoding") 144 | if tt.expectedGzip { 145 | assert.Equal(t, gzipEncoding, contentEncoding) 146 | } else { 147 | assert.Empty(t, contentEncoding, "404 responses should not have Content-Encoding: gzip") 148 | } 149 | 150 | // Verify that Vary header is also not set for uncompressed 404 responses 151 | if !tt.expectedGzip { 152 | vary := w.Header().Get("Vary") 153 | assert.Empty(t, vary, "404 responses should not have Vary header when not compressed") 154 | } 155 | }) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GZIP gin's middleware 2 | 3 | [![Run Tests](https://github.com/gin-contrib/gzip/actions/workflows/go.yml/badge.svg)](https://github.com/gin-contrib/gzip/actions/workflows/go.yml) 4 | [![Trivy Security Scan](https://github.com/gin-contrib/gzip/actions/workflows/trivy-scan.yml/badge.svg)](https://github.com/gin-contrib/gzip/actions/workflows/trivy-scan.yml) 5 | [![codecov](https://codecov.io/gh/gin-contrib/gzip/branch/master/graph/badge.svg)](https://codecov.io/gh/gin-contrib/gzip) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/gin-contrib/gzip)](https://goreportcard.com/report/github.com/gin-contrib/gzip) 7 | [![GoDoc](https://godoc.org/github.com/gin-contrib/gzip?status.svg)](https://godoc.org/github.com/gin-contrib/gzip) 8 | 9 | Gin middleware to enable `GZIP` support. 10 | 11 | ## Usage 12 | 13 | Download and install it: 14 | 15 | ```sh 16 | go get github.com/gin-contrib/gzip 17 | ``` 18 | 19 | Import it in your code: 20 | 21 | ```go 22 | import "github.com/gin-contrib/gzip" 23 | ``` 24 | 25 | Canonical example: 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | "fmt" 32 | "net/http" 33 | "time" 34 | 35 | "github.com/gin-contrib/gzip" 36 | "github.com/gin-gonic/gin" 37 | ) 38 | 39 | func main() { 40 | r := gin.Default() 41 | r.Use(gzip.Gzip(gzip.DefaultCompression)) 42 | r.GET("/ping", func(c *gin.Context) { 43 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 44 | }) 45 | 46 | // Listen and Server in 0.0.0.0:8080 47 | if err := r.Run(":8080"); err != nil { 48 | log.Fatal(err) 49 | } 50 | } 51 | ``` 52 | 53 | ### Compress only when response meets minimum byte size 54 | 55 | ```go 56 | package main 57 | 58 | import ( 59 | "log" 60 | "net/http" 61 | "strconv" 62 | "strings" 63 | 64 | "github.com/gin-contrib/gzip" 65 | "github.com/gin-gonic/gin" 66 | ) 67 | 68 | func main() { 69 | r := gin.Default() 70 | r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithMinLength(2048))) 71 | r.GET("/ping", func(c *gin.Context) { 72 | sizeStr := c.Query("size") 73 | size, _ := strconv.Atoi(sizeStr) 74 | c.String(http.StatusOK, strings.Repeat("a", size)) 75 | }) 76 | 77 | // Listen and Server in 0.0.0.0:8080 78 | if err := r.Run(":8080"); err != nil { 79 | log.Fatal(err) 80 | } 81 | } 82 | ``` 83 | Test with curl: 84 | ```bash 85 | curl -i --compressed 'http://localhost:8080/ping?size=2047' 86 | curl -i --compressed 'http://localhost:8080/ping?size=2048' 87 | ``` 88 | 89 | Notes: 90 | - If a "Content-Length" header is set, that will be used to determine whether to compress based on the given min length. 91 | - If no "Content-Length" header is set, a buffer is used to temporarily store writes until the min length is met or the request completes. 92 | - Setting a high min length will result in more buffering (2048 bytes is a recommended default for most cases) 93 | - The handler performs optimizations to avoid unnecessary operations, such as testing if `len(data)` exceeds min length before writing to the buffer, and reusing buffers between requests. 94 | 95 | ### Customized Excluded Extensions 96 | 97 | ```go 98 | package main 99 | 100 | import ( 101 | "fmt" 102 | "net/http" 103 | "time" 104 | 105 | "github.com/gin-contrib/gzip" 106 | "github.com/gin-gonic/gin" 107 | ) 108 | 109 | func main() { 110 | r := gin.Default() 111 | r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedExtensions([]string{".pdf", ".mp4"}))) 112 | r.GET("/ping", func(c *gin.Context) { 113 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 114 | }) 115 | 116 | // Listen and Server in 0.0.0.0:8080 117 | if err := r.Run(":8080"); err != nil { 118 | log.Fatal(err) 119 | } 120 | } 121 | ``` 122 | 123 | ### Customized Excluded Paths 124 | 125 | ```go 126 | package main 127 | 128 | import ( 129 | "fmt" 130 | "net/http" 131 | "time" 132 | 133 | "github.com/gin-contrib/gzip" 134 | "github.com/gin-gonic/gin" 135 | ) 136 | 137 | func main() { 138 | r := gin.Default() 139 | r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) 140 | r.GET("/ping", func(c *gin.Context) { 141 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 142 | }) 143 | 144 | // Listen and Server in 0.0.0.0:8080 145 | if err := r.Run(":8080"); err != nil { 146 | log.Fatal(err) 147 | } 148 | } 149 | ``` 150 | 151 | ### Customized Excluded Paths with Regex 152 | 153 | ```go 154 | package main 155 | 156 | import ( 157 | "fmt" 158 | "net/http" 159 | "time" 160 | 161 | "github.com/gin-contrib/gzip" 162 | "github.com/gin-gonic/gin" 163 | ) 164 | 165 | func main() { 166 | r := gin.Default() 167 | r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPathsRegexs([]string{".*"}))) 168 | r.GET("/ping", func(c *gin.Context) { 169 | c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().Unix())) 170 | }) 171 | 172 | // Listen and Server in 0.0.0.0:8080 173 | if err := r.Run(":8080"); err != nil { 174 | log.Fatal(err) 175 | } 176 | } 177 | ``` 178 | 179 | ### Server Push 180 | 181 | ```go 182 | package main 183 | 184 | import ( 185 | "fmt" 186 | "log" 187 | "net/http" 188 | "time" 189 | 190 | "github.com/gin-contrib/gzip" 191 | "github.com/gin-gonic/gin" 192 | ) 193 | 194 | func main() { 195 | r := gin.Default() 196 | r.Use(gzip.Gzip(gzip.DefaultCompression)) 197 | r.GET("/stream", func(c *gin.Context) { 198 | c.Header("Content-Type", "text/event-stream") 199 | c.Header("Connection", "keep-alive") 200 | for i := 0; i < 10; i++ { 201 | fmt.Fprintf(c.Writer, "id: %d\ndata: tick %d\n\n", i, time.Now().Unix()) 202 | c.Writer.Flush() 203 | time.Sleep(1 * time.Second) 204 | } 205 | }) 206 | 207 | // Listen and Server in 0.0.0.0:8080 208 | if err := r.Run(":8080"); err != nil { 209 | log.Fatal(err) 210 | } 211 | } 212 | ``` 213 | -------------------------------------------------------------------------------- /gzip.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "errors" 8 | "net" 9 | "net/http" 10 | "strconv" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | const ( 16 | BestCompression = gzip.BestCompression 17 | BestSpeed = gzip.BestSpeed 18 | DefaultCompression = gzip.DefaultCompression 19 | NoCompression = gzip.NoCompression 20 | HuffmanOnly = gzip.HuffmanOnly 21 | gzipEncoding = "gzip" 22 | ) 23 | 24 | func Gzip(level int, options ...Option) gin.HandlerFunc { 25 | return newGzipHandler(level, options...).Handle 26 | } 27 | 28 | type gzipWriter struct { 29 | gin.ResponseWriter 30 | writer *gzip.Writer 31 | statusWritten bool 32 | status int 33 | // minLength is the minimum length of the response body (in bytes) to enable compression 34 | minLength int 35 | // shouldCompress indicates whether the minimum length for compression has been met 36 | shouldCompress bool 37 | // buffer to store response data in case minimum length for compression wasn't met 38 | buffer bytes.Buffer 39 | } 40 | 41 | func (g *gzipWriter) WriteString(s string) (int, error) { 42 | return g.Write([]byte(s)) 43 | } 44 | 45 | // Write writes the given data to the appropriate underlying writer. 46 | // Note that this method can be called multiple times within a single request. 47 | func (g *gzipWriter) Write(data []byte) (int, error) { 48 | // Check status from ResponseWriter if not set via WriteHeader 49 | if !g.statusWritten { 50 | g.status = g.ResponseWriter.Status() 51 | } 52 | 53 | // For error responses (4xx, 5xx), don't compress 54 | // Always check the current status, even if WriteHeader was called 55 | if g.status >= 400 { 56 | g.removeGzipHeaders() 57 | return g.ResponseWriter.Write(data) 58 | } 59 | 60 | // Check if response is already gzip-compressed by looking at Content-Encoding header 61 | // If upstream handler already set gzip encoding, pass through without double compression 62 | if contentEncoding := g.Header().Get("Content-Encoding"); contentEncoding != "" && contentEncoding != gzipEncoding { 63 | // Different encoding, remove our gzip headers and pass through 64 | g.removeGzipHeaders() 65 | return g.ResponseWriter.Write(data) 66 | } else if contentEncoding == "gzip" { 67 | // Already gzip encoded by upstream, check if this looks like gzip data 68 | if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { 69 | // This is already gzip data, remove our headers and pass through 70 | g.removeGzipHeaders() 71 | return g.ResponseWriter.Write(data) 72 | } 73 | } 74 | 75 | // Now handle dynamic gzipping based on the client's specified minimum length 76 | // (if no min length specified, all responses get gzipped) 77 | // If a Content-Length header is set, use that to decide whether to compress so that we don't need to buffer 78 | if g.Header().Get("Content-Length") != "" { 79 | // invalid header treated the same as having no Content-Length 80 | contentLen, err := strconv.Atoi(g.Header().Get("Content-Length")) 81 | if err == nil { 82 | if contentLen < g.minLength { 83 | return g.ResponseWriter.Write(data) 84 | } 85 | g.shouldCompress = true 86 | g.Header().Del("Content-Length") 87 | } 88 | } 89 | 90 | // Handle buffering here if Content-Length value couldn't tell us whether to gzip 91 | // 92 | // Check if the response body is large enough to be compressed. 93 | // - If so, skip this condition and proceed with the normal write process. 94 | // - If not, store the data in the buffer (in case more data is written in future Write calls). 95 | // (At the end, if the response body is still too small, the caller should check shouldCompress and 96 | // use the data stored in the buffer to write the response instead.) 97 | if !g.shouldCompress && len(data) >= g.minLength { 98 | g.shouldCompress = true 99 | } else if !g.shouldCompress { 100 | lenWritten, err := g.buffer.Write(data) 101 | if err != nil || g.buffer.Len() < g.minLength { 102 | return lenWritten, err 103 | } 104 | g.shouldCompress = true 105 | data = g.buffer.Bytes() 106 | } 107 | 108 | return g.writer.Write(data) 109 | } 110 | 111 | // Status returns the HTTP response status code 112 | func (g *gzipWriter) Status() int { 113 | if g.statusWritten { 114 | return g.status 115 | } 116 | return g.ResponseWriter.Status() 117 | } 118 | 119 | // Size returns the number of bytes already written into the response http body 120 | func (g *gzipWriter) Size() int { 121 | return g.ResponseWriter.Size() 122 | } 123 | 124 | // Written returns true if the response body was already written 125 | func (g *gzipWriter) Written() bool { 126 | return g.ResponseWriter.Written() 127 | } 128 | 129 | // WriteHeaderNow forces to write the http header 130 | func (g *gzipWriter) WriteHeaderNow() { 131 | g.ResponseWriter.WriteHeaderNow() 132 | } 133 | 134 | // removeGzipHeaders removes compression-related headers for error responses 135 | func (g *gzipWriter) removeGzipHeaders() { 136 | g.Header().Del("Content-Encoding") 137 | g.Header().Del("Vary") 138 | g.Header().Del("ETag") 139 | } 140 | 141 | func (g *gzipWriter) Flush() { 142 | _ = g.writer.Flush() 143 | g.ResponseWriter.Flush() 144 | } 145 | 146 | // Fix: https://github.com/mholt/caddy/issues/38 147 | func (g *gzipWriter) WriteHeader(code int) { 148 | g.status = code 149 | g.statusWritten = true 150 | 151 | // Don't remove gzip headers immediately for error responses in WriteHeader 152 | // because some handlers (like static file server) may call WriteHeader multiple times 153 | // We'll check the status in Write() method when content is actually written 154 | 155 | g.ResponseWriter.WriteHeader(code) 156 | } 157 | 158 | // Ensure gzipWriter implements the http.Hijacker interface. 159 | // This will cause a compile-time error if gzipWriter does not implement all methods of the http.Hijacker interface. 160 | var _ http.Hijacker = (*gzipWriter)(nil) 161 | 162 | // Hijack allows the caller to take over the connection from the HTTP server. 163 | // After a call to Hijack, the HTTP server library will not do anything else with the connection. 164 | // It becomes the caller's responsibility to manage and close the connection. 165 | // 166 | // It returns the underlying net.Conn, a buffered reader/writer for the connection, and an error 167 | // if the ResponseWriter does not support the Hijacker interface. 168 | func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 169 | hijacker, ok := g.ResponseWriter.(http.Hijacker) 170 | if !ok { 171 | return nil, nil, errors.New("the ResponseWriter doesn't support the Hijacker interface") 172 | } 173 | return hijacker.Hijack() 174 | } 175 | -------------------------------------------------------------------------------- /_example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= 2 | github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 3 | github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= 4 | github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= 5 | github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 6 | github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 7 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= 8 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= 13 | github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 14 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 15 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 16 | github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= 17 | github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= 18 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 19 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 20 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 21 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 22 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 23 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 24 | github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= 25 | github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= 26 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 27 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 28 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 29 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 30 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 31 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 32 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 33 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 34 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 35 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 36 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 37 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 38 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 39 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 40 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 44 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 45 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 46 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 47 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= 51 | github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= 52 | github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= 53 | github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 60 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 61 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 62 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 63 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 64 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 65 | github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= 66 | github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 67 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= 68 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 69 | golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= 70 | golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 71 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 72 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 73 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 74 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 75 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 77 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 78 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 79 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 80 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 81 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 82 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 83 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 86 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 87 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 88 | -------------------------------------------------------------------------------- /static_test.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestStaticFileWithGzip(t *testing.T) { 17 | // Create a temporary directory and file for testing 18 | tmpDir, err := os.MkdirTemp("", "gzip_static_test") 19 | require.NoError(t, err) 20 | defer os.RemoveAll(tmpDir) 21 | 22 | // Create a test file 23 | testFile := filepath.Join(tmpDir, "test.txt") 24 | testContent := "This is a test file for static gzip compression testing. " + 25 | "It should be long enough to trigger gzip compression." 26 | err = os.WriteFile(testFile, []byte(testContent), 0o600) 27 | require.NoError(t, err) 28 | 29 | // Set up Gin router with gzip middleware and static file serving 30 | gin.SetMode(gin.TestMode) 31 | router := gin.New() 32 | router.Use(Gzip(DefaultCompression)) 33 | router.Static("/static", tmpDir) 34 | 35 | // Test static file request with gzip support 36 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/static/test.txt", nil) 37 | req.Header.Add(headerAcceptEncoding, "gzip") 38 | 39 | w := httptest.NewRecorder() 40 | router.ServeHTTP(w, req) 41 | 42 | // The response should be successful and compressed 43 | assert.Equal(t, http.StatusOK, w.Code) 44 | 45 | // This is what should happen but currently fails due to the bug 46 | // The static handler initially sets status to 404, causing gzip headers to be removed 47 | assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding), "Static file should be gzip compressed") 48 | assert.Equal(t, headerAcceptEncoding, w.Header().Get(headerVary), "Vary header should be set") 49 | 50 | // The compressed content should be smaller than original 51 | assert.Less(t, w.Body.Len(), len(testContent), "Compressed content should be smaller") 52 | } 53 | 54 | func TestStaticFileWithoutGzip(t *testing.T) { 55 | // Create a temporary directory and file for testing 56 | tmpDir, err := os.MkdirTemp("", "gzip_static_test") 57 | require.NoError(t, err) 58 | defer os.RemoveAll(tmpDir) 59 | 60 | // Create a test file 61 | testFile := filepath.Join(tmpDir, "test.txt") 62 | testContent := "This is a test file." 63 | err = os.WriteFile(testFile, []byte(testContent), 0o600) 64 | require.NoError(t, err) 65 | 66 | // Set up Gin router with gzip middleware and static file serving 67 | gin.SetMode(gin.TestMode) 68 | router := gin.New() 69 | router.Use(Gzip(DefaultCompression)) 70 | router.Static("/static", tmpDir) 71 | 72 | // Test static file request without gzip support 73 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/static/test.txt", nil) 74 | // No Accept-Encoding header 75 | 76 | w := httptest.NewRecorder() 77 | router.ServeHTTP(w, req) 78 | 79 | // The response should be successful and not compressed 80 | assert.Equal(t, http.StatusOK, w.Code) 81 | assert.Equal(t, "", w.Header().Get(headerContentEncoding), "Content should not be compressed") 82 | assert.Equal(t, "", w.Header().Get(headerVary), "Vary header should not be set") 83 | assert.Equal(t, testContent, w.Body.String(), "Content should match original") 84 | } 85 | 86 | func TestStaticFileNotFound(t *testing.T) { 87 | // Create a temporary directory (but no files) 88 | tmpDir, err := os.MkdirTemp("", "gzip_static_test") 89 | require.NoError(t, err) 90 | defer os.RemoveAll(tmpDir) 91 | 92 | // Set up Gin router with gzip middleware and static file serving 93 | gin.SetMode(gin.TestMode) 94 | router := gin.New() 95 | router.Use(Gzip(DefaultCompression)) 96 | router.Static("/static", tmpDir) 97 | 98 | // Test request for non-existent file 99 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/static/nonexistent.txt", nil) 100 | req.Header.Add(headerAcceptEncoding, "gzip") 101 | 102 | w := httptest.NewRecorder() 103 | router.ServeHTTP(w, req) 104 | 105 | // The response should be 404 and not compressed (this should work correctly) 106 | assert.Equal(t, http.StatusNotFound, w.Code) 107 | assert.Equal(t, "", w.Header().Get(headerContentEncoding), "404 response should not be compressed") 108 | assert.Equal(t, "", w.Header().Get(headerVary), "Vary header should be removed for error responses") 109 | } 110 | 111 | func TestStaticDirectoryListing(t *testing.T) { 112 | // Create a temporary directory with a file 113 | tmpDir, err := os.MkdirTemp("", "gzip_static_test") 114 | require.NoError(t, err) 115 | defer os.RemoveAll(tmpDir) 116 | 117 | // Create a test file 118 | testFile := filepath.Join(tmpDir, "test.txt") 119 | err = os.WriteFile(testFile, []byte("test content"), 0o600) 120 | require.NoError(t, err) 121 | 122 | // Set up Gin router with gzip middleware and static file serving 123 | gin.SetMode(gin.TestMode) 124 | router := gin.New() 125 | router.Use(Gzip(DefaultCompression)) 126 | router.Static("/static", tmpDir) 127 | 128 | // Test directory listing request 129 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/static/", nil) 130 | req.Header.Add(headerAcceptEncoding, "gzip") 131 | 132 | w := httptest.NewRecorder() 133 | router.ServeHTTP(w, req) 134 | 135 | // Note: Gin's default static handler doesn't enable directory listing 136 | // so this will return 404, which should NOT be compressed 137 | assert.Equal(t, http.StatusNotFound, w.Code) 138 | assert.Equal(t, "", w.Header().Get(headerContentEncoding), "404 response should not be compressed") 139 | assert.Equal(t, "", w.Header().Get(headerVary), "Vary header should be removed for error responses") 140 | } 141 | 142 | // This test demonstrates the specific issue mentioned in #122 143 | func TestStaticFileGzipHeadersBug(t *testing.T) { 144 | // Create a temporary directory and file for testing 145 | tmpDir, err := os.MkdirTemp("", "gzip_static_test") 146 | require.NoError(t, err) 147 | defer os.RemoveAll(tmpDir) 148 | 149 | // Create a test file 150 | testFile := filepath.Join(tmpDir, "test.js") 151 | testContent := "console.log('This is a JavaScript file that should be compressed when served as a static file');" 152 | err = os.WriteFile(testFile, []byte(testContent), 0o600) 153 | require.NoError(t, err) 154 | 155 | // Set up Gin router with gzip middleware and static file serving 156 | gin.SetMode(gin.TestMode) 157 | router := gin.New() 158 | router.Use(Gzip(DefaultCompression)) 159 | router.Static("/assets", tmpDir) 160 | 161 | // Test static file request with gzip support 162 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/assets/test.js", nil) 163 | req.Header.Add(headerAcceptEncoding, "gzip") 164 | 165 | w := httptest.NewRecorder() 166 | router.ServeHTTP(w, req) 167 | 168 | t.Logf("Response Status: %d", w.Code) 169 | t.Logf("Content-Encoding: %s", w.Header().Get(headerContentEncoding)) 170 | t.Logf("Vary: %s", w.Header().Get(headerVary)) 171 | t.Logf("Content-Length: %s", w.Header().Get("Content-Length")) 172 | t.Logf("Body Length: %d", w.Body.Len()) 173 | 174 | // This test will currently fail due to the bug described in issue #122 175 | // The static handler sets status to 404 initially, causing gzip middleware to remove headers 176 | assert.Equal(t, http.StatusOK, w.Code) 177 | 178 | // These assertions will fail with the current bug: 179 | // - Content-Encoding header will be empty instead of "gzip" 180 | // - Vary header will be empty instead of "Accept-Encoding" 181 | // - Content will not be compressed 182 | if w.Header().Get(headerContentEncoding) != gzipEncoding { 183 | t.Errorf("BUG REPRODUCED: Static file is not being gzip compressed. Content-Encoding: %q, expected: %q", 184 | w.Header().Get(headerContentEncoding), gzipEncoding) 185 | } 186 | 187 | if w.Header().Get(headerVary) != headerAcceptEncoding { 188 | t.Errorf("BUG REPRODUCED: Vary header missing. Vary: %q, expected: %q", 189 | w.Header().Get(headerVary), headerAcceptEncoding) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= 2 | github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 3 | github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= 4 | github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= 5 | github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 6 | github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 7 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= 8 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= 14 | github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 15 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 16 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 17 | github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= 18 | github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= 19 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 20 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 21 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 22 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 23 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 24 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 25 | github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= 26 | github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= 27 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 28 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 29 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 30 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 31 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 34 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 35 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 36 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 37 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 38 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 39 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 40 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 41 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 42 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 43 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 44 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 45 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 49 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 50 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 51 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 52 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= 56 | github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= 57 | github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= 58 | github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= 59 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 60 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 65 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 66 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 67 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 68 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 69 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 70 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 71 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 72 | github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= 73 | github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 74 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= 75 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 76 | golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= 77 | golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 78 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 79 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 80 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 81 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 84 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 85 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 86 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 87 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 88 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 89 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 90 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 93 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 96 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "compress/gzip" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | var ( 15 | // DefaultExcludedExtentions is a predefined list of file extensions that should be excluded from gzip compression. 16 | // These extensions typically represent image files that are already compressed 17 | // and do not benefit from additional compression. 18 | DefaultExcludedExtentions = NewExcludedExtensions([]string{ 19 | ".png", ".gif", ".jpeg", ".jpg", 20 | }) 21 | // ErrUnsupportedContentEncoding is an error that indicates the content encoding 22 | // is not supported by the application. 23 | ErrUnsupportedContentEncoding = errors.New("unsupported content encoding") 24 | ) 25 | 26 | // Option is an interface that defines a method to apply a configuration 27 | // to a given config instance. Implementations of this interface can be 28 | // used to modify the configuration settings of the logger. 29 | type Option interface { 30 | apply(*config) 31 | } 32 | 33 | // Ensures that optionFunc implements the Option interface at compile time. 34 | // If optionFunc does not implement Option, a compile-time error will occur. 35 | var _ Option = (*optionFunc)(nil) 36 | 37 | type optionFunc func(*config) 38 | 39 | func (o optionFunc) apply(c *config) { 40 | o(c) 41 | } 42 | 43 | type config struct { 44 | excludedExtensions ExcludedExtensions 45 | excludedPaths ExcludedPaths 46 | excludedPathesRegexs ExcludedPathesRegexs 47 | decompressFn func(c *gin.Context) 48 | decompressOnly bool 49 | customShouldCompressFn func(c *gin.Context) bool 50 | minLength int 51 | } 52 | 53 | // WithExcludedExtensions returns an Option that sets the ExcludedExtensions field of the Options struct. 54 | // Parameters: 55 | // - args: []string - A slice of file extensions to exclude from gzip compression. 56 | func WithExcludedExtensions(args []string) Option { 57 | return optionFunc(func(o *config) { 58 | o.excludedExtensions = NewExcludedExtensions(args) 59 | }) 60 | } 61 | 62 | // WithExcludedPaths returns an Option that sets the ExcludedPaths field of the Options struct. 63 | // Parameters: 64 | // - args: []string - A slice of paths to exclude from gzip compression. 65 | func WithExcludedPaths(args []string) Option { 66 | return optionFunc(func(o *config) { 67 | o.excludedPaths = NewExcludedPaths(args) 68 | }) 69 | } 70 | 71 | // WithExcludedPathsRegexs returns an Option that sets the ExcludedPathesRegexs field of the Options struct. 72 | // Parameters: 73 | // - args: []string - A slice of regex patterns to exclude paths from gzip compression. 74 | func WithExcludedPathsRegexs(args []string) Option { 75 | return optionFunc(func(o *config) { 76 | o.excludedPathesRegexs = NewExcludedPathesRegexs(args) 77 | }) 78 | } 79 | 80 | // WithDecompressFn returns an Option that sets the DecompressFn field of the Options struct. 81 | // Parameters: 82 | // - decompressFn: func(c *gin.Context) - A function to handle decompression of incoming requests. 83 | func WithDecompressFn(decompressFn func(c *gin.Context)) Option { 84 | return optionFunc(func(o *config) { 85 | o.decompressFn = decompressFn 86 | }) 87 | } 88 | 89 | // WithDecompressOnly is an option that configures the gzip middleware to only 90 | // decompress incoming requests without compressing the responses. When this 91 | // option is enabled, the middleware will set the DecompressOnly field of the 92 | // Options struct to true. 93 | func WithDecompressOnly() Option { 94 | return optionFunc(func(o *config) { 95 | o.decompressOnly = true 96 | }) 97 | } 98 | 99 | // WithCustomShouldCompressFn returns an Option that sets the CustomShouldCompressFn field of the Options struct. 100 | // Parameters: 101 | // - fn: func(c *gin.Context) bool - A function to determine if a request should be compressed. 102 | // The function should return true if the request should be compressed, false otherwise. 103 | // If the function returns false, the middleware will not compress the response. 104 | // If the function is nil, the middleware will use the default logic to determine 105 | // if the response should be compressed. 106 | // 107 | // Returns: 108 | // - Option - An option that sets the CustomShouldCompressFn field of the Options struct. 109 | // 110 | // Example: 111 | // 112 | // router.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithCustomShouldCompressFn(func(c *gin.Context) bool { 113 | // return c.Request.URL.Path != "/no-compress" 114 | // }))) 115 | func WithCustomShouldCompressFn(fn func(c *gin.Context) bool) Option { 116 | return optionFunc(func(o *config) { 117 | o.customShouldCompressFn = fn 118 | }) 119 | } 120 | 121 | // WithMinLength returns an Option that sets the minLength field of the Options struct. 122 | // Parameters: 123 | // - minLength: int - The minimum length of the response body (in bytes) to trigger gzip compression. 124 | // If the response body is smaller than this length, it will not be compressed. 125 | // This option is useful for avoiding the overhead of compression on small responses, especially since gzip 126 | // compression actually increases the size of small responses. 2048 is a recommended value for most cases. 127 | // The minLength value must be non-negative; negative values will cause undefined behavior. 128 | // 129 | // Note that specifying this option does not override other options. If a path has been excluded (eg through 130 | // WithExcludedPaths), it will continue to be excluded. 131 | // 132 | // Returns: 133 | // - Option - An option that sets the MinLength field of the Options struct. 134 | // 135 | // Example: 136 | // 137 | // router.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithMinLength(2048))) 138 | func WithMinLength(minLength int) Option { 139 | if minLength < 0 { 140 | panic("minLength must be non-negative") 141 | } 142 | return optionFunc(func(o *config) { 143 | o.minLength = minLength 144 | }) 145 | } 146 | 147 | // Using map for better lookup performance 148 | type ExcludedExtensions map[string]struct{} 149 | 150 | // NewExcludedExtensions creates a new ExcludedExtensions map from a slice of file extensions. 151 | // Parameters: 152 | // - extensions: []string - A slice of file extensions to exclude from gzip compression. 153 | // 154 | // Returns: 155 | // - ExcludedExtensions - A map of excluded file extensions. 156 | func NewExcludedExtensions(extensions []string) ExcludedExtensions { 157 | res := make(ExcludedExtensions, len(extensions)) 158 | for _, e := range extensions { 159 | res[e] = struct{}{} 160 | } 161 | return res 162 | } 163 | 164 | // Contains checks if a given file extension is in the ExcludedExtensions map. 165 | // Parameters: 166 | // - target: string - The file extension to check. 167 | // 168 | // Returns: 169 | // - bool - True if the extension is excluded, false otherwise. 170 | func (e ExcludedExtensions) Contains(target string) bool { 171 | _, ok := e[target] 172 | return ok 173 | } 174 | 175 | type ExcludedPaths []string 176 | 177 | // NewExcludedPaths creates a new ExcludedPaths slice from a slice of paths. 178 | // Parameters: 179 | // - paths: []string - A slice of paths to exclude from gzip compression. 180 | // 181 | // Returns: 182 | // - ExcludedPaths - A slice of excluded paths. 183 | func NewExcludedPaths(paths []string) ExcludedPaths { 184 | return ExcludedPaths(paths) 185 | } 186 | 187 | // Contains checks if a given request URI starts with any of the excluded paths. 188 | // Parameters: 189 | // - requestURI: string - The request URI to check. 190 | // 191 | // Returns: 192 | // - bool - True if the URI starts with an excluded path, false otherwise. 193 | func (e ExcludedPaths) Contains(requestURI string) bool { 194 | for _, path := range e { 195 | if strings.HasPrefix(requestURI, path) { 196 | return true 197 | } 198 | } 199 | return false 200 | } 201 | 202 | type ExcludedPathesRegexs []*regexp.Regexp 203 | 204 | // NewExcludedPathesRegexs creates a new ExcludedPathesRegexs slice from a slice of regex patterns. 205 | // Parameters: 206 | // - regexs: []string - A slice of regex patterns to exclude paths from gzip compression. 207 | // 208 | // Returns: 209 | // - ExcludedPathesRegexs - A slice of excluded path regex patterns. 210 | func NewExcludedPathesRegexs(regexs []string) ExcludedPathesRegexs { 211 | result := make(ExcludedPathesRegexs, len(regexs)) 212 | for i, reg := range regexs { 213 | result[i] = regexp.MustCompile(reg) 214 | } 215 | return result 216 | } 217 | 218 | // Contains checks if a given request URI matches any of the excluded path regex patterns. 219 | // Parameters: 220 | // - requestURI: string - The request URI to check. 221 | // 222 | // Returns: 223 | // - bool - True if the URI matches an excluded path regex pattern, false otherwise. 224 | func (e ExcludedPathesRegexs) Contains(requestURI string) bool { 225 | for _, reg := range e { 226 | if reg.MatchString(requestURI) { 227 | return true 228 | } 229 | } 230 | return false 231 | } 232 | 233 | // DefaultDecompressHandle is a middleware function for the Gin framework that 234 | // decompresses the request body if it is gzip encoded. It checks if the request 235 | // body is nil and returns immediately if it is. Otherwise, it attempts to create 236 | // a new gzip reader from the request body. If an error occurs during this process, 237 | // it aborts the request with a 400 Bad Request status and the error. If successful, 238 | // it removes the "Content-Encoding" and "Content-Length" headers from the request 239 | // and replaces the request body with the decompressed reader. 240 | // 241 | // Parameters: 242 | // - c: *gin.Context - The Gin context for the current request. 243 | func DefaultDecompressHandle(c *gin.Context) { 244 | if c.Request.Body == nil { 245 | return 246 | } 247 | 248 | contentEncodingField := strings.Split(strings.ToLower(c.GetHeader("Content-Encoding")), ",") 249 | if len(contentEncodingField) == 0 { // nothing to decompress 250 | c.Next() 251 | 252 | return 253 | } 254 | 255 | toClose := make([]io.Closer, 0, len(contentEncodingField)) 256 | defer func() { 257 | for i := len(toClose); i > 0; i-- { 258 | toClose[i-1].Close() 259 | } 260 | }() 261 | 262 | // parses multiply gzips like 263 | // Content-Encoding: gzip, gzip, gzip 264 | // allowed by RFC 265 | for i := 0; i < len(contentEncodingField); i++ { 266 | trimmedValue := strings.TrimSpace(contentEncodingField[i]) 267 | 268 | if trimmedValue == "" { 269 | continue 270 | } 271 | 272 | if trimmedValue != "gzip" { 273 | // According to RFC 7231, Section 3.1.2.2: 274 | // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.2 275 | // An origin server MAY respond with a status code of 415 (Unsupported 276 | // Media Type) if a representation in the request message has a content 277 | // coding that is not acceptable. 278 | _ = c.AbortWithError(http.StatusUnsupportedMediaType, ErrUnsupportedContentEncoding) 279 | } 280 | 281 | r, err := gzip.NewReader(c.Request.Body) 282 | if err != nil { 283 | _ = c.AbortWithError(http.StatusBadRequest, err) 284 | 285 | return 286 | } 287 | 288 | toClose = append(toClose, c.Request.Body) 289 | 290 | c.Request.Body = r 291 | } 292 | 293 | c.Request.Header.Del("Content-Encoding") 294 | c.Request.Header.Del("Content-Length") 295 | 296 | c.Next() 297 | } 298 | -------------------------------------------------------------------------------- /gzip_test.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "context" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/http/httputil" 14 | "net/url" 15 | "strconv" 16 | "strings" 17 | "testing" 18 | 19 | "github.com/gin-gonic/gin" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | const ( 25 | testResponse = "Gzip Test Response " 26 | testReverseResponse = "Gzip Test Reverse Response " 27 | ) 28 | 29 | type rServer struct{} 30 | 31 | func (s *rServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 32 | fmt.Fprint(rw, testReverseResponse) 33 | } 34 | 35 | type closeNotifyingRecorder struct { 36 | *httptest.ResponseRecorder 37 | closed chan bool 38 | } 39 | 40 | func newCloseNotifyingRecorder() *closeNotifyingRecorder { 41 | return &closeNotifyingRecorder{ 42 | httptest.NewRecorder(), 43 | make(chan bool, 1), 44 | } 45 | } 46 | 47 | func (c *closeNotifyingRecorder) CloseNotify() <-chan bool { 48 | return c.closed 49 | } 50 | 51 | func newServer() *gin.Engine { 52 | // init reverse proxy server 53 | rServer := httptest.NewServer(new(rServer)) 54 | target, _ := url.Parse(rServer.URL) 55 | rp := httputil.NewSingleHostReverseProxy(target) 56 | 57 | router := gin.New() 58 | router.Use(Gzip(DefaultCompression)) 59 | router.GET("/", func(c *gin.Context) { 60 | c.Header("Content-Length", strconv.Itoa(len(testResponse))) 61 | c.String(200, testResponse) 62 | }) 63 | router.GET("/ping", func(c *gin.Context) { 64 | c.Writer.Header().Add("Vary", "Origin") 65 | }, func(c *gin.Context) { 66 | c.Header("Content-Length", strconv.Itoa(len(testResponse))) 67 | c.String(200, testResponse) 68 | }) 69 | router.Any("/reverse", func(c *gin.Context) { 70 | rp.ServeHTTP(c.Writer, c.Request) 71 | }) 72 | return router 73 | } 74 | 75 | func TestVaryHeader(t *testing.T) { 76 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/ping", nil) 77 | req.Header.Add(headerAcceptEncoding, "gzip") 78 | 79 | w := httptest.NewRecorder() 80 | r := newServer() 81 | r.ServeHTTP(w, req) 82 | 83 | assert.Equal(t, 200, w.Code) 84 | assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding)) 85 | assert.Equal(t, []string{headerAcceptEncoding, "Origin"}, w.Header().Values(headerVary)) 86 | assert.NotEqual(t, "0", w.Header().Get("Content-Length")) 87 | assert.NotEqual(t, 19, w.Body.Len()) 88 | assert.Equal(t, w.Header().Get("Content-Length"), fmt.Sprint(w.Body.Len())) 89 | 90 | gr, err := gzip.NewReader(w.Body) 91 | assert.NoError(t, err) 92 | defer gr.Close() 93 | 94 | body, _ := io.ReadAll(gr) 95 | assert.Equal(t, testResponse, string(body)) 96 | } 97 | 98 | func TestGzip(t *testing.T) { 99 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 100 | req.Header.Add(headerAcceptEncoding, "gzip") 101 | 102 | w := httptest.NewRecorder() 103 | r := newServer() 104 | r.ServeHTTP(w, req) 105 | 106 | assert.Equal(t, w.Code, 200) 107 | assert.Equal(t, w.Header().Get(headerContentEncoding), "gzip") 108 | assert.Equal(t, w.Header().Get(headerVary), headerAcceptEncoding) 109 | assert.NotEqual(t, w.Header().Get("Content-Length"), "0") 110 | assert.NotEqual(t, w.Body.Len(), 19) 111 | assert.Equal(t, fmt.Sprint(w.Body.Len()), w.Header().Get("Content-Length")) 112 | 113 | gr, err := gzip.NewReader(w.Body) 114 | assert.NoError(t, err) 115 | defer gr.Close() 116 | 117 | body, _ := io.ReadAll(gr) 118 | assert.Equal(t, string(body), testResponse) 119 | } 120 | 121 | func TestGzipPNG(t *testing.T) { 122 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/image.png", nil) 123 | req.Header.Add(headerAcceptEncoding, "gzip") 124 | 125 | router := gin.New() 126 | router.Use(Gzip(DefaultCompression)) 127 | router.GET("/image.png", func(c *gin.Context) { 128 | c.String(200, "this is a PNG!") 129 | }) 130 | 131 | w := httptest.NewRecorder() 132 | router.ServeHTTP(w, req) 133 | 134 | assert.Equal(t, w.Code, 200) 135 | assert.Equal(t, w.Header().Get(headerContentEncoding), "") 136 | assert.Equal(t, w.Header().Get(headerVary), "") 137 | assert.Equal(t, w.Body.String(), "this is a PNG!") 138 | } 139 | 140 | func TestWriteString(t *testing.T) { 141 | testC, _ := gin.CreateTestContext(httptest.NewRecorder()) 142 | gz := gzipWriter{ 143 | ResponseWriter: testC.Writer, 144 | writer: gzip.NewWriter(testC.Writer), 145 | } 146 | n, err := gz.WriteString("test") 147 | assert.NoError(t, err) 148 | assert.Equal(t, 4, n) 149 | } 150 | 151 | func TestExcludedPathsAndExtensions(t *testing.T) { 152 | tests := []struct { 153 | path string 154 | option Option 155 | expectedContentEncoding string 156 | expectedVary string 157 | expectedBody string 158 | expectedContentLength string 159 | }{ 160 | {"/api/books", WithExcludedPaths([]string{"/api/"}), "", "", "this is books!", ""}, 161 | {"/index.html", WithExcludedExtensions([]string{".html"}), "", "", "this is a HTML!", ""}, 162 | } 163 | 164 | for _, tt := range tests { 165 | req, _ := http.NewRequestWithContext(context.Background(), "GET", tt.path, nil) 166 | req.Header.Add(headerAcceptEncoding, "gzip") 167 | 168 | router := gin.New() 169 | router.Use(Gzip(DefaultCompression, tt.option)) 170 | router.GET(tt.path, func(c *gin.Context) { 171 | c.String(200, tt.expectedBody) 172 | }) 173 | 174 | w := httptest.NewRecorder() 175 | router.ServeHTTP(w, req) 176 | 177 | assert.Equal(t, http.StatusOK, w.Code) 178 | assert.Equal(t, tt.expectedContentEncoding, w.Header().Get(headerContentEncoding)) 179 | assert.Equal(t, tt.expectedVary, w.Header().Get(headerVary)) 180 | assert.Equal(t, tt.expectedBody, w.Body.String()) 181 | assert.Equal(t, tt.expectedContentLength, w.Header().Get("Content-Length")) 182 | } 183 | } 184 | 185 | func TestNoGzip(t *testing.T) { 186 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 187 | 188 | w := httptest.NewRecorder() 189 | r := newServer() 190 | r.ServeHTTP(w, req) 191 | 192 | assert.Equal(t, w.Code, 200) 193 | assert.Equal(t, w.Header().Get(headerContentEncoding), "") 194 | assert.Equal(t, w.Header().Get("Content-Length"), "19") 195 | assert.Equal(t, w.Body.String(), testResponse) 196 | } 197 | 198 | func TestGzipWithReverseProxy(t *testing.T) { 199 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/reverse", nil) 200 | req.Header.Add(headerAcceptEncoding, "gzip") 201 | 202 | w := newCloseNotifyingRecorder() 203 | r := newServer() 204 | r.ServeHTTP(w, req) 205 | 206 | assert.Equal(t, w.Code, 200) 207 | assert.Equal(t, w.Header().Get(headerContentEncoding), "gzip") 208 | assert.Equal(t, w.Header().Get(headerVary), headerAcceptEncoding) 209 | assert.NotEqual(t, w.Header().Get("Content-Length"), "0") 210 | assert.NotEqual(t, w.Body.Len(), 19) 211 | assert.Equal(t, fmt.Sprint(w.Body.Len()), w.Header().Get("Content-Length")) 212 | 213 | gr, err := gzip.NewReader(w.Body) 214 | assert.NoError(t, err) 215 | defer gr.Close() 216 | 217 | body, _ := io.ReadAll(gr) 218 | assert.Equal(t, string(body), testReverseResponse) 219 | } 220 | 221 | func TestDecompressGzip(t *testing.T) { 222 | buf := &bytes.Buffer{} 223 | gz, _ := gzip.NewWriterLevel(buf, gzip.DefaultCompression) 224 | if _, err := gz.Write([]byte(testResponse)); err != nil { 225 | gz.Close() 226 | t.Fatal(err) 227 | } 228 | gz.Close() 229 | 230 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", buf) 231 | req.Header.Add(headerContentEncoding, "gzip") 232 | 233 | router := gin.New() 234 | router.Use(Gzip(DefaultCompression, WithDecompressFn(DefaultDecompressHandle))) 235 | router.POST("/", func(c *gin.Context) { 236 | if v := c.Request.Header.Get(headerContentEncoding); v != "" { 237 | t.Errorf("unexpected `Content-Encoding`: %s header", v) 238 | } 239 | if v := c.Request.Header.Get("Content-Length"); v != "" { 240 | t.Errorf("unexpected `Content-Length`: %s header", v) 241 | } 242 | data, err := c.GetRawData() 243 | if err != nil { 244 | t.Fatal(err) 245 | } 246 | c.Data(200, "text/plain", data) 247 | }) 248 | 249 | w := httptest.NewRecorder() 250 | router.ServeHTTP(w, req) 251 | 252 | assert.Equal(t, http.StatusOK, w.Code) 253 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 254 | assert.Equal(t, "", w.Header().Get(headerVary)) 255 | assert.Equal(t, testResponse, w.Body.String()) 256 | assert.Equal(t, "", w.Header().Get("Content-Length")) 257 | } 258 | 259 | func TestDecompressGzipWithEmptyBody(t *testing.T) { 260 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", nil) 261 | req.Header.Add(headerContentEncoding, "gzip") 262 | 263 | router := gin.New() 264 | router.Use(Gzip(DefaultCompression, WithDecompressFn(DefaultDecompressHandle))) 265 | router.POST("/", func(c *gin.Context) { 266 | c.String(200, "ok") 267 | }) 268 | 269 | w := httptest.NewRecorder() 270 | router.ServeHTTP(w, req) 271 | 272 | assert.Equal(t, http.StatusOK, w.Code) 273 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 274 | assert.Equal(t, "", w.Header().Get(headerVary)) 275 | assert.Equal(t, "ok", w.Body.String()) 276 | assert.Equal(t, "", w.Header().Get("Content-Length")) 277 | } 278 | 279 | func TestDecompressGzipWithIncorrectData(t *testing.T) { 280 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", bytes.NewReader([]byte(testResponse))) 281 | req.Header.Add(headerContentEncoding, "gzip") 282 | 283 | router := gin.New() 284 | router.Use(Gzip(DefaultCompression, WithDecompressFn(DefaultDecompressHandle))) 285 | router.POST("/", func(c *gin.Context) { 286 | c.String(200, "ok") 287 | }) 288 | 289 | w := httptest.NewRecorder() 290 | router.ServeHTTP(w, req) 291 | 292 | assert.Equal(t, http.StatusBadRequest, w.Code) 293 | } 294 | 295 | func TestDecompressOnly(t *testing.T) { 296 | buf := &bytes.Buffer{} 297 | gz, _ := gzip.NewWriterLevel(buf, gzip.DefaultCompression) 298 | if _, err := gz.Write([]byte(testResponse)); err != nil { 299 | gz.Close() 300 | t.Fatal(err) 301 | } 302 | gz.Close() 303 | 304 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", buf) 305 | req.Header.Add(headerContentEncoding, "gzip") 306 | 307 | router := gin.New() 308 | router.Use(Gzip(NoCompression, WithDecompressOnly(), WithDecompressFn(DefaultDecompressHandle))) 309 | router.POST("/", func(c *gin.Context) { 310 | if v := c.Request.Header.Get(headerContentEncoding); v != "" { 311 | t.Errorf("unexpected `Content-Encoding`: %s header", v) 312 | } 313 | if v := c.Request.Header.Get("Content-Length"); v != "" { 314 | t.Errorf("unexpected `Content-Length`: %s header", v) 315 | } 316 | data, err := c.GetRawData() 317 | if err != nil { 318 | t.Fatal(err) 319 | } 320 | c.Data(200, "text/plain", data) 321 | }) 322 | 323 | w := httptest.NewRecorder() 324 | router.ServeHTTP(w, req) 325 | 326 | assert.Equal(t, http.StatusOK, w.Code) 327 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 328 | assert.Equal(t, "", w.Header().Get(headerVary)) 329 | assert.Equal(t, testResponse, w.Body.String()) 330 | assert.Equal(t, "", w.Header().Get("Content-Length")) 331 | } 332 | 333 | func TestGzipWithDecompressOnly(t *testing.T) { 334 | buf := &bytes.Buffer{} 335 | gz, _ := gzip.NewWriterLevel(buf, gzip.DefaultCompression) 336 | if _, err := gz.Write([]byte(testResponse)); err != nil { 337 | gz.Close() 338 | t.Fatal(err) 339 | } 340 | gz.Close() 341 | 342 | req, _ := http.NewRequestWithContext(context.Background(), "POST", "/", buf) 343 | req.Header.Add(headerContentEncoding, "gzip") 344 | req.Header.Add(headerAcceptEncoding, "gzip") 345 | 346 | r := gin.New() 347 | r.Use(Gzip(NoCompression, WithDecompressOnly(), WithDecompressFn(DefaultDecompressHandle))) 348 | r.POST("/", func(c *gin.Context) { 349 | assert.Equal(t, c.Request.Header.Get(headerContentEncoding), "") 350 | assert.Equal(t, c.Request.Header.Get("Content-Length"), "") 351 | body, err := c.GetRawData() 352 | if err != nil { 353 | t.Fatal(err) 354 | } 355 | assert.Equal(t, testResponse, string(body)) 356 | c.String(200, testResponse) 357 | }) 358 | 359 | w := httptest.NewRecorder() 360 | r.ServeHTTP(w, req) 361 | 362 | assert.Equal(t, 200, w.Code) 363 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 364 | assert.Equal(t, testResponse, w.Body.String()) 365 | } 366 | 367 | func TestCustomShouldCompressFn(t *testing.T) { 368 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 369 | req.Header.Add(headerAcceptEncoding, "gzip") 370 | 371 | router := gin.New() 372 | router.Use(Gzip( 373 | DefaultCompression, 374 | WithCustomShouldCompressFn(func(_ *gin.Context) bool { 375 | return false 376 | }), 377 | )) 378 | router.GET("/", func(c *gin.Context) { 379 | c.Header("Content-Length", strconv.Itoa(len(testResponse))) 380 | c.String(200, testResponse) 381 | }) 382 | 383 | w := httptest.NewRecorder() 384 | router.ServeHTTP(w, req) 385 | 386 | assert.Equal(t, 200, w.Code) 387 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 388 | assert.Equal(t, "19", w.Header().Get("Content-Length")) 389 | assert.Equal(t, testResponse, w.Body.String()) 390 | } 391 | 392 | func TestMinLengthInvalidValue(t *testing.T) { 393 | defer func() { 394 | if r := recover(); r == nil { 395 | t.Errorf("Invalid minLength should cause panic") 396 | } 397 | }() 398 | 399 | router := gin.New() 400 | router.Use(Gzip(DefaultCompression, WithMinLength(-1))) 401 | } 402 | 403 | func TestMinLengthShortResponse(t *testing.T) { 404 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 405 | req.Header.Add(headerAcceptEncoding, "gzip") 406 | 407 | router := gin.New() 408 | router.Use(Gzip(DefaultCompression, WithMinLength(2048))) 409 | router.GET("/", func(c *gin.Context) { 410 | c.String(200, testResponse) 411 | }) 412 | 413 | w := httptest.NewRecorder() 414 | router.ServeHTTP(w, req) 415 | 416 | assert.Equal(t, 200, w.Code) 417 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 418 | assert.Equal(t, "19", w.Header().Get("Content-Length")) 419 | assert.Equal(t, testResponse, w.Body.String()) 420 | } 421 | 422 | func TestMinLengthLongResponse(t *testing.T) { 423 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 424 | req.Header.Add(headerAcceptEncoding, "gzip") 425 | 426 | router := gin.New() 427 | router.Use(Gzip(DefaultCompression, WithMinLength(2048))) 428 | router.GET("/", func(c *gin.Context) { 429 | c.String(200, strings.Repeat("a", 2048)) 430 | }) 431 | 432 | w := httptest.NewRecorder() 433 | router.ServeHTTP(w, req) 434 | 435 | assert.Equal(t, 200, w.Code) 436 | assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding)) 437 | assert.NotEqual(t, "2048", w.Header().Get("Content-Length")) 438 | assert.Less(t, w.Body.Len(), 2048) 439 | } 440 | 441 | func TestMinLengthMultiWriteResponse(t *testing.T) { 442 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 443 | req.Header.Add(headerAcceptEncoding, "gzip") 444 | 445 | router := gin.New() 446 | router.Use(Gzip(DefaultCompression, WithMinLength(2048))) 447 | router.GET("/", func(c *gin.Context) { 448 | c.String(200, strings.Repeat("a", 1024)) 449 | c.String(200, strings.Repeat("b", 1024)) 450 | }) 451 | 452 | w := httptest.NewRecorder() 453 | router.ServeHTTP(w, req) 454 | 455 | assert.Equal(t, 200, w.Code) 456 | assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding)) 457 | assert.NotEqual(t, "2048", w.Header().Get("Content-Length")) 458 | assert.Less(t, w.Body.Len(), 2048) 459 | } 460 | 461 | // Note this test intentionally triggers gzipping even when the actual response doesn't meet min length. This is because 462 | // we use the Content-Length header as the primary determinant of compression to avoid the cost of buffering. 463 | func TestMinLengthUsesContentLengthHeaderInsteadOfBuffering(t *testing.T) { 464 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 465 | req.Header.Add(headerAcceptEncoding, "gzip") 466 | 467 | router := gin.New() 468 | router.Use(Gzip(DefaultCompression, WithMinLength(2048))) 469 | router.GET("/", func(c *gin.Context) { 470 | c.Header("Content-Length", "2048") 471 | c.String(200, testResponse) 472 | }) 473 | 474 | w := httptest.NewRecorder() 475 | router.ServeHTTP(w, req) 476 | 477 | assert.Equal(t, 200, w.Code) 478 | assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding)) 479 | assert.NotEmpty(t, w.Header().Get("Content-Length")) 480 | assert.NotEqual(t, "19", w.Header().Get("Content-Length")) 481 | } 482 | 483 | // Note this test intentionally does not trigger gzipping even when the actual response meets min length. This is 484 | // because we use the Content-Length header as the primary determinant of compression to avoid the cost of buffering. 485 | func TestMinLengthMultiWriteResponseUsesContentLengthHeaderInsteadOfBuffering(t *testing.T) { 486 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 487 | req.Header.Add(headerAcceptEncoding, "gzip") 488 | 489 | router := gin.New() 490 | router.Use(Gzip(DefaultCompression, WithMinLength(1024))) 491 | router.GET("/", func(c *gin.Context) { 492 | c.Header("Content-Length", "999") 493 | c.String(200, strings.Repeat("a", 1024)) 494 | c.String(200, strings.Repeat("b", 1024)) 495 | }) 496 | 497 | w := httptest.NewRecorder() 498 | router.ServeHTTP(w, req) 499 | 500 | assert.Equal(t, 200, w.Code) 501 | // no gzip since Content-Length doesn't meet min length 1024 502 | assert.NotEqual(t, "gzip", w.Header().Get(headerContentEncoding)) 503 | assert.Equal(t, "2048", w.Header().Get("Content-Length")) 504 | } 505 | 506 | func TestMinLengthWithInvalidContentLengthHeader(t *testing.T) { 507 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 508 | req.Header.Add(headerAcceptEncoding, "gzip") 509 | 510 | router := gin.New() 511 | router.Use(Gzip(DefaultCompression, WithMinLength(2048))) 512 | router.GET("/", func(c *gin.Context) { 513 | c.Header("Content-Length", "xyz") 514 | c.String(200, testResponse) 515 | }) 516 | 517 | w := httptest.NewRecorder() 518 | router.ServeHTTP(w, req) 519 | 520 | assert.Equal(t, 200, w.Code) 521 | assert.Equal(t, "", w.Header().Get(headerContentEncoding)) 522 | assert.Equal(t, "19", w.Header().Get("Content-Length")) 523 | } 524 | 525 | func TestFlush(t *testing.T) { 526 | testC, _ := gin.CreateTestContext(httptest.NewRecorder()) 527 | gz := gzipWriter{ 528 | ResponseWriter: testC.Writer, 529 | writer: gzip.NewWriter(testC.Writer), 530 | } 531 | _, _ = gz.WriteString("test") 532 | gz.Flush() 533 | assert.True(t, gz.Written()) 534 | } 535 | 536 | type hijackableResponse struct { 537 | Hijacked bool 538 | header http.Header 539 | } 540 | 541 | func newHijackableResponse() *hijackableResponse { 542 | return &hijackableResponse{header: make(http.Header)} 543 | } 544 | 545 | func (h *hijackableResponse) Header() http.Header { return h.header } 546 | func (h *hijackableResponse) Write([]byte) (int, error) { return 0, nil } 547 | func (h *hijackableResponse) WriteHeader(int) {} 548 | func (h *hijackableResponse) Flush() {} 549 | func (h *hijackableResponse) Hijack() (net.Conn, *bufio.ReadWriter, error) { 550 | h.Hijacked = true 551 | return nil, nil, nil 552 | } 553 | 554 | func TestResponseWriterHijack(t *testing.T) { 555 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) 556 | req.Header.Add(headerAcceptEncoding, "gzip") 557 | 558 | router := gin.New() 559 | router.Use(Gzip( 560 | DefaultCompression, 561 | WithCustomShouldCompressFn(func(_ *gin.Context) bool { 562 | return false 563 | }), 564 | )).Use(gin.HandlerFunc(func(c *gin.Context) { 565 | hj, ok := c.Writer.(http.Hijacker) 566 | require.True(t, ok) 567 | 568 | _, _, err := hj.Hijack() 569 | assert.Nil(t, err) 570 | c.Next() 571 | })) 572 | router.GET("/", func(c *gin.Context) { 573 | c.Header("Content-Length", strconv.Itoa(len(testResponse))) 574 | c.String(200, testResponse) 575 | }) 576 | 577 | hijackable := newHijackableResponse() 578 | router.ServeHTTP(hijackable, req) 579 | assert.True(t, hijackable.Hijacked) 580 | } 581 | 582 | func TestDoubleGzipCompression(t *testing.T) { 583 | // Create a test server that returns gzip-compressed content 584 | compressedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 585 | // Compress the response body 586 | buf := &bytes.Buffer{} 587 | gz := gzip.NewWriter(buf) 588 | _, err := gz.Write([]byte(testReverseResponse)) 589 | require.NoError(t, err) 590 | require.NoError(t, gz.Close()) 591 | 592 | // Set gzip headers to simulate already compressed content 593 | w.Header().Set(headerContentEncoding, "gzip") 594 | w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) 595 | w.WriteHeader(200) 596 | _, err = w.Write(buf.Bytes()) 597 | require.NoError(t, err) 598 | })) 599 | defer compressedServer.Close() 600 | 601 | // Parse the server URL for the reverse proxy 602 | target, err := url.Parse(compressedServer.URL) 603 | require.NoError(t, err) 604 | rp := httputil.NewSingleHostReverseProxy(target) 605 | 606 | // Create gin router with gzip middleware 607 | router := gin.New() 608 | router.Use(Gzip(DefaultCompression)) 609 | router.Any("/proxy", func(c *gin.Context) { 610 | rp.ServeHTTP(c.Writer, c.Request) 611 | }) 612 | 613 | // Make request through the proxy 614 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/proxy", nil) 615 | req.Header.Add(headerAcceptEncoding, "gzip") 616 | 617 | w := newCloseNotifyingRecorder() 618 | router.ServeHTTP(w, req) 619 | 620 | assert.Equal(t, 200, w.Code) 621 | 622 | // Check if response is compressed - should still be gzip since upstream provided gzip 623 | // But it should NOT be double compressed 624 | responseBody := w.Body.Bytes() 625 | 626 | // First check if the response body looks like gzip (starts with gzip magic number) 627 | if len(responseBody) >= 2 && responseBody[0] == 0x1f && responseBody[1] == 0x8b { 628 | // Response is gzip compressed, try to decompress once 629 | gr, err := gzip.NewReader(bytes.NewReader(responseBody)) 630 | assert.NoError(t, err, "Response should be decompressible with single gzip decompression") 631 | defer gr.Close() 632 | 633 | body, err := io.ReadAll(gr) 634 | assert.NoError(t, err) 635 | assert.Equal(t, testReverseResponse, string(body), 636 | "Response should match original content after single decompression") 637 | 638 | // Ensure no double compression - decompressed content should not be gzip 639 | if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b { 640 | t.Error("Response appears to be double-compressed - " + 641 | "single decompression revealed another gzip stream") 642 | } 643 | } else { 644 | // Response is not gzip compressed, check if content matches 645 | assert.Equal(t, testReverseResponse, w.Body.String(), "Uncompressed response should match original content") 646 | } 647 | } 648 | 649 | func TestPrometheusMetricsDoubleCompression(t *testing.T) { 650 | // Simulate Prometheus metrics server that returns gzip-compressed metrics 651 | prometheusData := `# HELP http_requests_total Total number of HTTP requests 652 | # TYPE http_requests_total counter 653 | http_requests_total{method="get",status="200"} 1027 1395066363000 654 | http_requests_total{method="get",status="400"} 3 1395066363000` 655 | 656 | prometheusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 657 | // Prometheus server compresses its own response 658 | buf := &bytes.Buffer{} 659 | gz := gzip.NewWriter(buf) 660 | _, err := gz.Write([]byte(prometheusData)) 661 | require.NoError(t, err) 662 | require.NoError(t, gz.Close()) 663 | 664 | w.Header().Set(headerContentEncoding, "gzip") 665 | w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") 666 | w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) 667 | w.WriteHeader(200) 668 | _, err = w.Write(buf.Bytes()) 669 | require.NoError(t, err) 670 | })) 671 | defer prometheusServer.Close() 672 | 673 | // Create reverse proxy to Prometheus server 674 | target, err := url.Parse(prometheusServer.URL) 675 | require.NoError(t, err) 676 | rp := httputil.NewSingleHostReverseProxy(target) 677 | 678 | // Create gin router with gzip middleware (like what would happen in a gateway) 679 | router := gin.New() 680 | router.Use(Gzip(DefaultCompression)) 681 | router.Any("/metrics", func(c *gin.Context) { 682 | rp.ServeHTTP(c.Writer, c.Request) 683 | }) 684 | 685 | // Simulate Prometheus scraper request 686 | req, _ := http.NewRequestWithContext(context.Background(), "GET", "/metrics", nil) 687 | req.Header.Add(headerAcceptEncoding, "gzip") 688 | req.Header.Add("User-Agent", "Prometheus/2.37.0") 689 | 690 | w := newCloseNotifyingRecorder() 691 | router.ServeHTTP(w, req) 692 | 693 | assert.Equal(t, 200, w.Code) 694 | 695 | // Check if response is gzip compressed and handle accordingly 696 | responseBody := w.Body.Bytes() 697 | 698 | // First check if the response body looks like gzip (starts with gzip magic number) 699 | if len(responseBody) >= 2 && responseBody[0] == 0x1f && responseBody[1] == 0x8b { 700 | // Response is gzip compressed, try to decompress once 701 | gr, err := gzip.NewReader(bytes.NewReader(responseBody)) 702 | assert.NoError(t, err, "Prometheus should be able to decompress the metrics response") 703 | defer gr.Close() 704 | 705 | body, err := io.ReadAll(gr) 706 | assert.NoError(t, err) 707 | assert.Equal(t, prometheusData, string(body), "Metrics content should be correct after decompression") 708 | 709 | // Verify no double compression - decompressed content should not be gzip 710 | if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b { 711 | t.Error("Metrics response appears to be double-compressed - Prometheus scraping would fail") 712 | } 713 | } else { 714 | // Response is not gzip compressed, check if content matches 715 | assert.Equal(t, prometheusData, w.Body.String(), "Uncompressed metrics should match original content") 716 | } 717 | } 718 | --------------------------------------------------------------------------------