From 75881359ab3331e7111a2dbf90eb872256309eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolfgang=20Ells=C3=A4sser?= Date: Wed, 7 Aug 2024 16:20:04 +0200 Subject: [PATCH] Add encodings option to the compression middleware --- docs/content/middlewares/http/compress.md | 45 +++++ .../dynamic-configuration/docker-labels.yml | 1 + .../reference/dynamic-configuration/file.toml | 1 + .../reference/dynamic-configuration/file.yaml | 3 + .../kubernetes-crd-definition-v1.yml | 8 +- .../reference/dynamic-configuration/kv-ref.md | 2 + .../traefik.io_middlewares.yaml | 8 +- integration/fixtures/k8s/01-traefik-crd.yml | 8 +- pkg/config/dynamic/middlewares.go | 9 +- pkg/config/dynamic/zz_generated.deepcopy.go | 5 + pkg/config/label/label_test.go | 10 ++ pkg/middlewares/compress/acceptencoding.go | 30 ++-- .../compress/acceptencoding_test.go | 161 ++++++++++++------ pkg/middlewares/compress/compress.go | 24 ++- pkg/middlewares/compress/compress_test.go | 50 ++++-- pkg/provider/kubernetes/crd/kubernetes.go | 39 ++++- .../crd/traefikio/v1alpha1/middleware.go | 24 ++- .../v1alpha1/zz_generated.deepcopy.go | 48 +++++- pkg/provider/kv/kv_test.go | 5 + 19 files changed, 389 insertions(+), 92 deletions(-) diff --git a/docs/content/middlewares/http/compress.md b/docs/content/middlewares/http/compress.md index f26fe622c..46028ee0b 100644 --- a/docs/content/middlewares/http/compress.md +++ b/docs/content/middlewares/http/compress.md @@ -255,3 +255,48 @@ http: [http.middlewares.test-compress.compress] defaultEncoding = "gzip" ``` + +### `encodings` + +_Optional, Default="zstd, br, gzip"_ + +`encodings` specifies the list of supported compression encodings. +At least one encoding value must be specified, and valid entries are `zstd` (Zstandard), `br` (Brotli), and `gzip` (Gzip). +The order of the list also sets the priority, the top entry has the highest priority. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-compress.compress.encodings=zstd,br" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-compress +spec: + compress: + encodings: + - zstd + - br +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-compress.compress.encodings=zstd,br" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-compress: + compress: + encodings: + - zstd + - br +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-compress.compress] + encodings = ["zstd","br"] +``` diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index 4abb97126..e9b9beabc 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -19,6 +19,7 @@ - "traefik.http.middlewares.middleware05.circuitbreaker.responsecode=42" - "traefik.http.middlewares.middleware06.compress=true" - "traefik.http.middlewares.middleware06.compress.defaultencoding=foobar" +- "traefik.http.middlewares.middleware06.compress.encodings=foobar, foobar" - "traefik.http.middlewares.middleware06.compress.excludedcontenttypes=foobar, foobar" - "traefik.http.middlewares.middleware06.compress.includedcontenttypes=foobar, foobar" - "traefik.http.middlewares.middleware06.compress.minresponsebodybytes=42" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a45ceee14..62958ccd4 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -143,6 +143,7 @@ excludedContentTypes = ["foobar", "foobar"] includedContentTypes = ["foobar", "foobar"] minResponseBodyBytes = 42 + encodings = ["foobar", "foobar"] defaultEncoding = "foobar" [http.middlewares.Middleware07] [http.middlewares.Middleware07.contentType] diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index df0fef94c..e4e82bea4 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -152,6 +152,9 @@ http: - foobar - foobar minResponseBodyBytes: 42 + encodings: + - foobar + - foobar defaultEncoding: foobar Middleware07: contentType: diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 330ccb552..4ff865c9b 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -904,7 +904,7 @@ spec: compress: description: |- Compress holds the compress middleware configuration. - This middleware compresses responses before sending them to the client, using gzip compression. + This middleware compresses responses before sending them to the client, using gzip, brotli, or zstd compression. More info: https://doc.traefik.io/traefik/v3.1/middlewares/http/compress/ properties: defaultEncoding: @@ -912,6 +912,12 @@ spec: the `Accept-Encoding` header is not in the request or contains a wildcard (`*`). type: string + encodings: + description: Encodings defines the list of supported compression + algorithms. + items: + type: string + type: array excludedContentTypes: description: |- ExcludedContentTypes defines the list of content types to compare the Content-Type header of the incoming requests and responses before compressing. diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index ac6a42f3d..205ef0efe 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -22,6 +22,8 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/middlewares/Middleware05/circuitBreaker/recoveryDuration` | `42s` | | `traefik/http/middlewares/Middleware05/circuitBreaker/responseCode` | `42` | | `traefik/http/middlewares/Middleware06/compress/defaultEncoding` | `foobar` | +| `traefik/http/middlewares/Middleware06/compress/encodings/0` | `foobar` | +| `traefik/http/middlewares/Middleware06/compress/encodings/1` | `foobar` | | `traefik/http/middlewares/Middleware06/compress/excludedContentTypes/0` | `foobar` | | `traefik/http/middlewares/Middleware06/compress/excludedContentTypes/1` | `foobar` | | `traefik/http/middlewares/Middleware06/compress/includedContentTypes/0` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml index 16ddfccb4..561ba5430 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml @@ -180,7 +180,7 @@ spec: compress: description: |- Compress holds the compress middleware configuration. - This middleware compresses responses before sending them to the client, using gzip compression. + This middleware compresses responses before sending them to the client, using gzip, brotli, or zstd compression. More info: https://doc.traefik.io/traefik/v3.1/middlewares/http/compress/ properties: defaultEncoding: @@ -188,6 +188,12 @@ spec: the `Accept-Encoding` header is not in the request or contains a wildcard (`*`). type: string + encodings: + description: Encodings defines the list of supported compression + algorithms. + items: + type: string + type: array excludedContentTypes: description: |- ExcludedContentTypes defines the list of content types to compare the Content-Type header of the incoming requests and responses before compressing. diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 330ccb552..4ff865c9b 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -904,7 +904,7 @@ spec: compress: description: |- Compress holds the compress middleware configuration. - This middleware compresses responses before sending them to the client, using gzip compression. + This middleware compresses responses before sending them to the client, using gzip, brotli, or zstd compression. More info: https://doc.traefik.io/traefik/v3.1/middlewares/http/compress/ properties: defaultEncoding: @@ -912,6 +912,12 @@ spec: the `Accept-Encoding` header is not in the request or contains a wildcard (`*`). type: string + encodings: + description: Encodings defines the list of supported compression + algorithms. + items: + type: string + type: array excludedContentTypes: description: |- ExcludedContentTypes defines the list of content types to compare the Content-Type header of the incoming requests and responses before compressing. diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 51ff1be8c..36fc64104 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -165,8 +165,7 @@ func (c *CircuitBreaker) SetDefaults() { // +k8s:deepcopy-gen=true // Compress holds the compress middleware configuration. -// This middleware compresses responses before sending them to the client, using gzip compression. -// More info: https://doc.traefik.io/traefik/v3.1/middlewares/http/compress/ +// This middleware compresses responses before sending them to the client, using gzip, brotli, or zstd compression. type Compress struct { // ExcludedContentTypes defines the list of content types to compare the Content-Type header of the incoming requests and responses before compressing. // `application/grpc` is always excluded. @@ -176,10 +175,16 @@ type Compress struct { // MinResponseBodyBytes defines the minimum amount of bytes a response body must have to be compressed. // Default: 1024. MinResponseBodyBytes int `json:"minResponseBodyBytes,omitempty" toml:"minResponseBodyBytes,omitempty" yaml:"minResponseBodyBytes,omitempty" export:"true"` + // Encodings defines the list of supported compression algorithms. + Encodings []string `json:"encodings,omitempty" toml:"encodings,omitempty" yaml:"encodings,omitempty" export:"true"` // DefaultEncoding specifies the default encoding if the `Accept-Encoding` header is not in the request or contains a wildcard (`*`). DefaultEncoding string `json:"defaultEncoding,omitempty" toml:"defaultEncoding,omitempty" yaml:"defaultEncoding,omitempty" export:"true"` } +func (c *Compress) SetDefaults() { + c.Encodings = []string{"zstd", "br", "gzip"} +} + // +k8s:deepcopy-gen=true // DigestAuth holds the digest auth middleware configuration. diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index bf34dd55a..2011b2f6b 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -158,6 +158,11 @@ func (in *Compress) DeepCopyInto(out *Compress) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Encodings != nil { + in, out := &in.Encodings, &out.Encodings + *out = make([]string, len(*in)) + copy(*out, *in) + } return } diff --git a/pkg/config/label/label_test.go b/pkg/config/label/label_test.go index c9989e1e3..ce020a438 100644 --- a/pkg/config/label/label_test.go +++ b/pkg/config/label/label_test.go @@ -137,6 +137,7 @@ func TestDecodeConfiguration(t *testing.T) { "traefik.http.middlewares.Middleware17.stripprefix.prefixes": "foobar, fiibar", "traefik.http.middlewares.Middleware17.stripprefix.forceslash": "true", "traefik.http.middlewares.Middleware18.stripprefixregex.regex": "foobar, fiibar", + "traefik.http.middlewares.Middleware19.compress.encodings": "foobar, fiibar", "traefik.http.middlewares.Middleware19.compress.minresponsebodybytes": "42", "traefik.http.middlewares.Middleware20.plugin.tomato.aaa": "foo1", "traefik.http.middlewares.Middleware20.plugin.tomato.bbb": "foo2", @@ -493,6 +494,10 @@ func TestDecodeConfiguration(t *testing.T) { "Middleware19": { Compress: &dynamic.Compress{ MinResponseBodyBytes: 42, + Encodings: []string{ + "foobar", + "fiibar", + }, }, }, "Middleware2": { @@ -1009,6 +1014,10 @@ func TestEncodeConfiguration(t *testing.T) { "Middleware19": { Compress: &dynamic.Compress{ MinResponseBodyBytes: 42, + Encodings: []string{ + "foobar", + "fiibar", + }, }, }, "Middleware2": { @@ -1377,6 +1386,7 @@ func TestEncodeConfiguration(t *testing.T) { "traefik.HTTP.Middlewares.Middleware17.StripPrefix.Prefixes": "foobar, fiibar", "traefik.HTTP.Middlewares.Middleware17.StripPrefix.ForceSlash": "true", "traefik.HTTP.Middlewares.Middleware18.StripPrefixRegex.Regex": "foobar, fiibar", + "traefik.HTTP.Middlewares.Middleware19.Compress.Encodings": "foobar, fiibar", "traefik.HTTP.Middlewares.Middleware19.Compress.MinResponseBodyBytes": "42", "traefik.HTTP.Middlewares.Middleware20.Plugin.tomato.aaa": "foo1", "traefik.HTTP.Middlewares.Middleware20.Plugin.tomato.bbb": "foo2", diff --git a/pkg/middlewares/compress/acceptencoding.go b/pkg/middlewares/compress/acceptencoding.go index 3f9fc4f4a..8458b7f28 100644 --- a/pkg/middlewares/compress/acceptencoding.go +++ b/pkg/middlewares/compress/acceptencoding.go @@ -22,13 +22,18 @@ type Encoding struct { Weight *float64 } -func getCompressionType(acceptEncoding []string, defaultType string) string { - if defaultType == "" { - // Keeps the pre-existing default inside Traefik. - defaultType = brotliName +func getCompressionEncoding(acceptEncoding []string, defaultEncoding string, supportedEncodings []string) string { + if defaultEncoding == "" { + if slices.Contains(supportedEncodings, brotliName) { + // Keeps the pre-existing default inside Traefik if brotli is a supported encoding. + defaultEncoding = brotliName + } else if len(supportedEncodings) > 0 { + // Otherwise use the first supported encoding. + defaultEncoding = supportedEncodings[0] + } } - encodings, hasWeight := parseAcceptEncoding(acceptEncoding) + encodings, hasWeight := parseAcceptEncoding(acceptEncoding, supportedEncodings) if hasWeight { if len(encodings) == 0 { @@ -46,26 +51,26 @@ func getCompressionType(acceptEncoding []string, defaultType string) string { } if encoding.Type == wildcardName { - return defaultType + return defaultEncoding } return encoding.Type } - for _, dt := range []string{zstdName, brotliName, gzipName} { + for _, dt := range supportedEncodings { if slices.ContainsFunc(encodings, func(e Encoding) bool { return e.Type == dt }) { return dt } } if slices.ContainsFunc(encodings, func(e Encoding) bool { return e.Type == wildcardName }) { - return defaultType + return defaultEncoding } return identityName } -func parseAcceptEncoding(acceptEncoding []string) ([]Encoding, bool) { +func parseAcceptEncoding(acceptEncoding, supportedEncodings []string) ([]Encoding, bool) { var encodings []Encoding var hasWeight bool @@ -76,10 +81,9 @@ func parseAcceptEncoding(acceptEncoding []string) ([]Encoding, bool) { continue } - switch parsed[0] { - case zstdName, brotliName, gzipName, identityName, wildcardName: - // supported encoding - default: + if !slices.Contains(supportedEncodings, parsed[0]) && + parsed[0] != identityName && + parsed[0] != wildcardName { continue } diff --git a/pkg/middlewares/compress/acceptencoding_test.go b/pkg/middlewares/compress/acceptencoding_test.go index 818c3e06e..ef4b980a1 100644 --- a/pkg/middlewares/compress/acceptencoding_test.go +++ b/pkg/middlewares/compress/acceptencoding_test.go @@ -6,73 +6,86 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_getCompressionType(t *testing.T) { +func Test_getCompressionEncoding(t *testing.T) { testCases := []struct { - desc string - values []string - defaultType string - expected string + desc string + acceptEncoding []string + defaultEncoding string + supportedEncodings []string + expected string }{ { - desc: "br > gzip (no weight)", - values: []string{"gzip, br"}, - expected: brotliName, + desc: "br > gzip (no weight)", + acceptEncoding: []string{"gzip, br"}, + expected: brotliName, }, { - desc: "zstd > br > gzip (no weight)", - values: []string{"zstd, gzip, br"}, - expected: zstdName, + desc: "zstd > br > gzip (no weight)", + acceptEncoding: []string{"zstd, gzip, br"}, + expected: zstdName, }, { - desc: "known compression type (no weight)", - values: []string{"compress, gzip"}, - expected: gzipName, + desc: "known compression encoding (no weight)", + acceptEncoding: []string{"compress, gzip"}, + expected: gzipName, }, { - desc: "unknown compression type (no weight), no encoding", - values: []string{"compress, rar"}, - expected: identityName, + desc: "unknown compression encoding (no weight), no encoding", + acceptEncoding: []string{"compress, rar"}, + expected: identityName, }, { - desc: "wildcard return the default compression type", - values: []string{"*"}, - expected: brotliName, + desc: "wildcard return the default compression encoding", + acceptEncoding: []string{"*"}, + expected: brotliName, }, { - desc: "wildcard return the custom default compression type", - values: []string{"*"}, - defaultType: "foo", - expected: "foo", + desc: "wildcard return the custom default compression encoding", + acceptEncoding: []string{"*"}, + defaultEncoding: "foo", + expected: "foo", }, { - desc: "follows weight", - values: []string{"br;q=0.8, gzip;q=1.0, *;q=0.1"}, - expected: gzipName, + desc: "follows weight", + acceptEncoding: []string{"br;q=0.8, gzip;q=1.0, *;q=0.1"}, + expected: gzipName, }, { - desc: "ignore unknown compression type", - values: []string{"compress;q=1.0, gzip;q=0.5"}, - expected: gzipName, + desc: "ignore unknown compression encoding", + acceptEncoding: []string{"compress;q=1.0, gzip;q=0.5"}, + expected: gzipName, }, { - desc: "fallback on non-zero compression type", - values: []string{"compress;q=1.0, gzip, identity;q=0"}, - expected: gzipName, + desc: "fallback on non-zero compression encoding", + acceptEncoding: []string{"compress;q=1.0, gzip, identity;q=0"}, + expected: gzipName, }, { - desc: "not acceptable (identity)", - values: []string{"compress;q=1.0, identity;q=0"}, - expected: notAcceptable, + desc: "not acceptable (identity)", + acceptEncoding: []string{"compress;q=1.0, identity;q=0"}, + expected: notAcceptable, }, { - desc: "not acceptable (wildcard)", - values: []string{"compress;q=1.0, *;q=0"}, - expected: notAcceptable, + desc: "not acceptable (wildcard)", + acceptEncoding: []string{"compress;q=1.0, *;q=0"}, + expected: notAcceptable, }, { - desc: "non-zero is higher than 0", - values: []string{"gzip, *;q=0"}, - expected: gzipName, + desc: "non-zero is higher than 0", + acceptEncoding: []string{"gzip, *;q=0"}, + expected: gzipName, + }, + { + desc: "zstd forbidden, brotli first", + acceptEncoding: []string{"zstd, gzip, br"}, + supportedEncodings: []string{brotliName, gzipName}, + expected: brotliName, + }, + { + desc: "follows weight, ignores forbidden encoding", + acceptEncoding: []string{"br;q=0.8, gzip;q=1.0, *;q=0.1"}, + supportedEncodings: []string{zstdName, brotliName}, + expected: brotliName, }, } @@ -80,19 +93,24 @@ func Test_getCompressionType(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - encodingType := getCompressionType(test.values, test.defaultType) + if test.supportedEncodings == nil { + test.supportedEncodings = defaultSupportedEncodings + } - assert.Equal(t, test.expected, encodingType) + encoding := getCompressionEncoding(test.acceptEncoding, test.defaultEncoding, test.supportedEncodings) + + assert.Equal(t, test.expected, encoding) }) } } func Test_parseAcceptEncoding(t *testing.T) { testCases := []struct { - desc string - values []string - expected []Encoding - assertWeight assert.BoolAssertionFunc + desc string + values []string + supportedEncodings []string + expected []Encoding + assertWeight assert.BoolAssertionFunc }{ { desc: "weight", @@ -105,6 +123,17 @@ func Test_parseAcceptEncoding(t *testing.T) { }, assertWeight: assert.True, }, + { + desc: "weight with supported encodings", + values: []string{"br;q=1.0, zstd;q=0.9, gzip;q=0.8, *;q=0.1"}, + supportedEncodings: []string{brotliName, gzipName}, + expected: []Encoding{ + {Type: brotliName, Weight: ptr[float64](1)}, + {Type: gzipName, Weight: ptr(0.8)}, + {Type: wildcardName, Weight: ptr(0.1)}, + }, + assertWeight: assert.True, + }, { desc: "mixed", values: []string{"zstd,gzip, br;q=1.0, *;q=0"}, @@ -116,6 +145,16 @@ func Test_parseAcceptEncoding(t *testing.T) { }, assertWeight: assert.True, }, + { + desc: "mixed with supported encodings", + values: []string{"zstd,gzip, br;q=1.0, *;q=0"}, + supportedEncodings: []string{zstdName}, + expected: []Encoding{ + {Type: zstdName}, + {Type: wildcardName, Weight: ptr[float64](0)}, + }, + assertWeight: assert.True, + }, { desc: "no weight", values: []string{"zstd, gzip, br, *"}, @@ -127,6 +166,16 @@ func Test_parseAcceptEncoding(t *testing.T) { }, assertWeight: assert.False, }, + { + desc: "no weight with supported encodings", + values: []string{"zstd, gzip, br, *"}, + supportedEncodings: []string{"gzip"}, + expected: []Encoding{ + {Type: gzipName}, + {Type: wildcardName}, + }, + assertWeight: assert.False, + }, { desc: "weight and identity", values: []string{"gzip;q=1.0, identity; q=0.5, *;q=0"}, @@ -137,13 +186,27 @@ func Test_parseAcceptEncoding(t *testing.T) { }, assertWeight: assert.True, }, + { + desc: "weight and identity", + values: []string{"gzip;q=1.0, identity; q=0.5, *;q=0"}, + supportedEncodings: []string{"br"}, + expected: []Encoding{ + {Type: identityName, Weight: ptr(0.5)}, + {Type: wildcardName, Weight: ptr[float64](0)}, + }, + assertWeight: assert.True, + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - aes, hasWeight := parseAcceptEncoding(test.values) + if test.supportedEncodings == nil { + test.supportedEncodings = defaultSupportedEncodings + } + + aes, hasWeight := parseAcceptEncoding(test.values, test.supportedEncodings) assert.Equal(t, test.expected, aes) test.assertWeight(t, hasWeight) diff --git a/pkg/middlewares/compress/compress.go b/pkg/middlewares/compress/compress.go index 3ccac9f2d..d3205e3a7 100644 --- a/pkg/middlewares/compress/compress.go +++ b/pkg/middlewares/compress/compress.go @@ -16,9 +16,11 @@ import ( const typeName = "Compress" -// DefaultMinSize is the default minimum size (in bytes) required to enable compression. +// defaultMinSize is the default minimum size (in bytes) required to enable compression. // See https://github.com/klauspost/compress/blob/9559b037e79ad673c71f6ef7c732c00949014cd2/gzhttp/compress.go#L47. -const DefaultMinSize = 1024 +const defaultMinSize = 1024 + +var defaultSupportedEncodings = []string{zstdName, brotliName, gzipName} // Compress is a middleware that allows to compress the response. type compress struct { @@ -27,6 +29,7 @@ type compress struct { excludes []string includes []string minSize int + encodings []string defaultEncoding string brotliHandler http.Handler @@ -62,17 +65,30 @@ func New(ctx context.Context, next http.Handler, conf dynamic.Compress, name str includes = append(includes, mediaType) } - minSize := DefaultMinSize + minSize := defaultMinSize if conf.MinResponseBodyBytes > 0 { minSize = conf.MinResponseBodyBytes } + if len(conf.Encodings) == 0 { + return nil, errors.New("at least one encoding must be specified") + } + for _, encoding := range conf.Encodings { + if !slices.Contains(defaultSupportedEncodings, encoding) { + return nil, fmt.Errorf("unsupported encoding: %s", encoding) + } + } + if conf.DefaultEncoding != "" && !slices.Contains(conf.Encodings, conf.DefaultEncoding) { + return nil, fmt.Errorf("unsupported default encoding: %s", conf.DefaultEncoding) + } + c := &compress{ next: next, name: name, excludes: excludes, includes: includes, minSize: minSize, + encodings: conf.Encodings, defaultEncoding: conf.DefaultEncoding, } @@ -131,7 +147,7 @@ func (c *compress) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - c.chooseHandler(getCompressionType(acceptEncoding, c.defaultEncoding), rw, req) + c.chooseHandler(getCompressionEncoding(acceptEncoding, c.defaultEncoding, c.encodings), rw, req) } func (c *compress) chooseHandler(typ string, rw http.ResponseWriter, req *http.Request) { diff --git a/pkg/middlewares/compress/compress_test.go b/pkg/middlewares/compress/compress_test.go index af127df0c..586dfcdd6 100644 --- a/pkg/middlewares/compress/compress_test.go +++ b/pkg/middlewares/compress/compress_test.go @@ -102,7 +102,11 @@ func TestNegotiation(t *testing.T) { next := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { _, _ = rw.Write(generateBytes(10)) }) - handler, err := New(context.Background(), next, dynamic.Compress{MinResponseBodyBytes: 1}, "testing") + cfg := dynamic.Compress{ + MinResponseBodyBytes: 1, + Encodings: defaultSupportedEncodings, + } + handler, err := New(context.Background(), next, cfg, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -123,7 +127,7 @@ func TestShouldCompressWhenNoContentEncodingHeader(t *testing.T) { _, err := rw.Write(baseBody) assert.NoError(t, err) }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -153,7 +157,7 @@ func TestShouldNotCompressWhenContentEncodingHeader(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -175,7 +179,7 @@ func TestShouldNotCompressWhenNoAcceptEncodingHeader(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -202,7 +206,7 @@ func TestShouldNotCompressWhenIdentityAcceptEncodingHeader(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -229,7 +233,7 @@ func TestShouldNotCompressWhenEmptyAcceptEncodingHeader(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -251,7 +255,7 @@ func TestShouldNotCompressHeadRequest(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -274,6 +278,7 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) { { desc: "Exclude Request Content-Type", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, ExcludedContentTypes: []string{"text/event-stream"}, }, reqContentType: "text/event-stream", @@ -281,6 +286,7 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) { { desc: "Exclude Response Content-Type", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, ExcludedContentTypes: []string{"text/event-stream"}, }, respContentType: "text/event-stream", @@ -288,6 +294,7 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) { { desc: "Include Response Content-Type", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, IncludedContentTypes: []string{"text/plain"}, }, respContentType: "text/html", @@ -295,6 +302,7 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) { { desc: "Ignoring application/grpc with exclude option", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, ExcludedContentTypes: []string{"application/json"}, }, reqContentType: "application/grpc", @@ -302,13 +310,16 @@ func TestShouldNotCompressWhenSpecificContentType(t *testing.T) { { desc: "Ignoring application/grpc with include option", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, IncludedContentTypes: []string{"application/json"}, }, reqContentType: "application/grpc", }, { - desc: "Ignoring application/grpc with no option", - conf: dynamic.Compress{}, + desc: "Ignoring application/grpc with no option", + conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, + }, reqContentType: "application/grpc", }, } @@ -358,6 +369,7 @@ func TestShouldCompressWhenSpecificContentType(t *testing.T) { { desc: "Include Response Content-Type", conf: dynamic.Compress{ + Encodings: defaultSupportedEncodings, IncludedContentTypes: []string{"text/html"}, }, respContentType: "text/html", @@ -429,7 +441,7 @@ func TestIntegrationShouldNotCompress(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - compress, err := New(context.Background(), test.handler, dynamic.Compress{}, "testing") + compress, err := New(context.Background(), test.handler, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) ts := httptest.NewServer(compress) @@ -464,7 +476,7 @@ func TestShouldWriteHeaderWhenFlush(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - handler, err := New(context.Background(), next, dynamic.Compress{}, "testing") + handler, err := New(context.Background(), next, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) ts := httptest.NewServer(handler) @@ -515,7 +527,7 @@ func TestIntegrationShouldCompress(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - compress, err := New(context.Background(), test.handler, dynamic.Compress{}, "testing") + compress, err := New(context.Background(), test.handler, dynamic.Compress{Encodings: defaultSupportedEncodings}, "testing") require.NoError(t, err) ts := httptest.NewServer(compress) @@ -571,8 +583,11 @@ func TestMinResponseBodyBytes(t *testing.T) { http.Error(rw, err.Error(), http.StatusInternalServerError) } }) - - handler, err := New(context.Background(), next, dynamic.Compress{MinResponseBodyBytes: test.minResponseBodyBytes}, "testing") + cfg := dynamic.Compress{ + MinResponseBodyBytes: test.minResponseBodyBytes, + Encodings: defaultSupportedEncodings, + } + handler, err := New(context.Background(), next, cfg, "testing") require.NoError(t, err) rw := httptest.NewRecorder() @@ -607,8 +622,11 @@ func Test1xxResponses(t *testing.T) { http.Error(w, err.Error(), http.StatusInternalServerError) } }) - - compress, err := New(context.Background(), next, dynamic.Compress{MinResponseBodyBytes: 1024}, "testing") + cfg := dynamic.Compress{ + MinResponseBodyBytes: 1024, + Encodings: defaultSupportedEncodings, + } + compress, err := New(context.Background(), next, cfg, "testing") require.NoError(t, err) server := httptest.NewServer(compress) diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index 752ba88a7..41b4eff08 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -304,7 +304,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client) InFlightReq: middleware.Spec.InFlightReq, Buffering: middleware.Spec.Buffering, CircuitBreaker: circuitBreaker, - Compress: middleware.Spec.Compress, + Compress: createCompressMiddleware(middleware.Spec.Compress), PassTLSClientCert: middleware.Spec.PassTLSClientCert, Retry: retry, ContentType: middleware.Spec.ContentType, @@ -655,14 +655,49 @@ func createCircuitBreakerMiddleware(circuitBreaker *traefikv1alpha1.CircuitBreak return cb, nil } +func createCompressMiddleware(compress *traefikv1alpha1.Compress) *dynamic.Compress { + if compress == nil { + return nil + } + + c := &dynamic.Compress{} + c.SetDefaults() + + if compress.ExcludedContentTypes != nil { + c.ExcludedContentTypes = compress.ExcludedContentTypes + } + + if compress.IncludedContentTypes != nil { + c.IncludedContentTypes = compress.IncludedContentTypes + } + + if compress.MinResponseBodyBytes != nil { + c.MinResponseBodyBytes = *compress.MinResponseBodyBytes + } + + if compress.Encodings != nil { + c.Encodings = compress.Encodings + } + + if compress.DefaultEncoding != nil { + c.DefaultEncoding = *compress.DefaultEncoding + } + + return c +} + func createRateLimitMiddleware(rateLimit *traefikv1alpha1.RateLimit) (*dynamic.RateLimit, error) { if rateLimit == nil { return nil, nil } - rl := &dynamic.RateLimit{Average: rateLimit.Average} + rl := &dynamic.RateLimit{} rl.SetDefaults() + if rateLimit.Average != nil { + rl.Average = *rateLimit.Average + } + if rateLimit.Burst != nil { rl.Burst = *rateLimit.Burst } diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go index ca3598e48..92c0b370d 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go @@ -46,7 +46,7 @@ type MiddlewareSpec struct { InFlightReq *dynamic.InFlightReq `json:"inFlightReq,omitempty"` Buffering *dynamic.Buffering `json:"buffering,omitempty"` CircuitBreaker *CircuitBreaker `json:"circuitBreaker,omitempty"` - Compress *dynamic.Compress `json:"compress,omitempty"` + Compress *Compress `json:"compress,omitempty"` PassTLSClientCert *dynamic.PassTLSClientCert `json:"passTLSClientCert,omitempty"` Retry *Retry `json:"retry,omitempty"` ContentType *dynamic.ContentType `json:"contentType,omitempty"` @@ -188,7 +188,7 @@ type RateLimit struct { // It defaults to 0, which means no rate limiting. // The rate is actually defined by dividing Average by Period. So for a rate below 1req/s, // one needs to define a Period larger than a second. - Average int64 `json:"average,omitempty"` + Average *int64 `json:"average,omitempty"` // Period, in combination with Average, defines the actual maximum rate, such as: // r = Average / Period. It defaults to a second. Period *intstr.IntOrString `json:"period,omitempty"` @@ -203,6 +203,26 @@ type RateLimit struct { // +k8s:deepcopy-gen=true +// Compress holds the compress middleware configuration. +// This middleware compresses responses before sending them to the client, using gzip, brotli, or zstd compression. +// More info: https://doc.traefik.io/traefik/v3.1/middlewares/http/compress/ +type Compress struct { + // ExcludedContentTypes defines the list of content types to compare the Content-Type header of the incoming requests and responses before compressing. + // `application/grpc` is always excluded. + ExcludedContentTypes []string `json:"excludedContentTypes,omitempty"` + // IncludedContentTypes defines the list of content types to compare the Content-Type header of the responses before compressing. + IncludedContentTypes []string `json:"includedContentTypes,omitempty"` + // MinResponseBodyBytes defines the minimum amount of bytes a response body must have to be compressed. + // Default: 1024. + MinResponseBodyBytes *int `json:"minResponseBodyBytes,omitempty"` + // Encodings defines the list of supported compression algorithms. + Encodings []string `json:"encodings,omitempty"` + // DefaultEncoding specifies the default encoding if the `Accept-Encoding` header is not in the request or contains a wildcard (`*`). + DefaultEncoding *string `json:"defaultEncoding,omitempty"` +} + +// +k8s:deepcopy-gen=true + // Retry holds the retry middleware configuration. // This middleware reissues requests a given number of times to a backend server if that server does not reply. // As soon as the server answers, the middleware stops retrying, regardless of the response status. diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go index 8ff42f478..caa1bf9ec 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go @@ -164,6 +164,47 @@ func (in *ClientTLS) DeepCopy() *ClientTLS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Compress) DeepCopyInto(out *Compress) { + *out = *in + if in.ExcludedContentTypes != nil { + in, out := &in.ExcludedContentTypes, &out.ExcludedContentTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.IncludedContentTypes != nil { + in, out := &in.IncludedContentTypes, &out.IncludedContentTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.MinResponseBodyBytes != nil { + in, out := &in.MinResponseBodyBytes, &out.MinResponseBodyBytes + *out = new(int) + **out = **in + } + if in.Encodings != nil { + in, out := &in.Encodings, &out.Encodings + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DefaultEncoding != nil { + in, out := &in.DefaultEncoding, &out.DefaultEncoding + *out = new(string) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Compress. +func (in *Compress) DeepCopy() *Compress { + if in == nil { + return nil + } + out := new(Compress) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DigestAuth) DeepCopyInto(out *DigestAuth) { *out = *in @@ -776,7 +817,7 @@ func (in *MiddlewareSpec) DeepCopyInto(out *MiddlewareSpec) { } if in.Compress != nil { in, out := &in.Compress, &out.Compress - *out = new(dynamic.Compress) + *out = new(Compress) (*in).DeepCopyInto(*out) } if in.PassTLSClientCert != nil { @@ -975,6 +1016,11 @@ func (in *ObjectReference) DeepCopy() *ObjectReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RateLimit) DeepCopyInto(out *RateLimit) { *out = *in + if in.Average != nil { + in, out := &in.Average, &out.Average + *out = new(int64) + **out = **in + } if in.Period != nil { in, out := &in.Period, &out.Period *out = new(intstr.IntOrString) diff --git a/pkg/provider/kv/kv_test.go b/pkg/provider/kv/kv_test.go index 679bb258f..3610ec3b5 100644 --- a/pkg/provider/kv/kv_test.go +++ b/pkg/provider/kv/kv_test.go @@ -207,6 +207,7 @@ func Test_buildConfiguration(t *testing.T) { "traefik/http/middlewares/Middleware02/buffering/retryExpression": "foobar", "traefik/http/middlewares/Middleware02/buffering/maxRequestBodyBytes": "42", "traefik/http/middlewares/Middleware02/buffering/memRequestBodyBytes": "42", + "traefik/http/middlewares/Middleware05/compress/encodings": "foobar, foobar", "traefik/http/middlewares/Middleware05/compress/minResponseBodyBytes": "42", "traefik/http/middlewares/Middleware18/retry/attempts": "42", "traefik/http/middlewares/Middleware19/stripPrefix/prefixes/0": "foobar", @@ -412,6 +413,10 @@ func Test_buildConfiguration(t *testing.T) { "Middleware05": { Compress: &dynamic.Compress{ MinResponseBodyBytes: 42, + Encodings: []string{ + "foobar", + "foobar", + }, }, }, "Middleware08": {