This is an automated email from the ASF dual-hosted git repository. ronething pushed a commit to branch feat/response-rewrite in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
commit 908f168858bc05f284f62794a8b27607e6939503 Author: Ashing Zheng <[email protected]> AuthorDate: Thu Oct 30 11:48:59 2025 +0800 feat: support response rewrite annotations for ingress Signed-off-by: Ashing Zheng <[email protected]> --- .../adc/translator/annotations/plugins/plugins.go | 1 + .../annotations/plugins/response_rewrite.go | 89 +++++++++++++ .../annotations/plugins/response_rewrite_test.go | 139 +++++++++++++++++++++ internal/webhook/v1/ingress_webhook.go | 7 -- test/e2e/ingress/annotations.go | 108 ++++++++++++++++ 5 files changed, 337 insertions(+), 7 deletions(-) diff --git a/internal/adc/translator/annotations/plugins/plugins.go b/internal/adc/translator/annotations/plugins/plugins.go index 2dd8e5f8..f49fe8f8 100644 --- a/internal/adc/translator/annotations/plugins/plugins.go +++ b/internal/adc/translator/annotations/plugins/plugins.go @@ -40,6 +40,7 @@ var ( NewCorsHandler(), NewCSRFHandler(), NewFaultInjectionHandler(), + NewResponseRewriteHandler(), } ) diff --git a/internal/adc/translator/annotations/plugins/response_rewrite.go b/internal/adc/translator/annotations/plugins/response_rewrite.go new file mode 100644 index 00000000..7de62238 --- /dev/null +++ b/internal/adc/translator/annotations/plugins/response_rewrite.go @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugins + +import ( + "strconv" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +type responseRewrite struct{} + +// NewResponseRewriteHandler creates a handler to convert annotations about +// ResponseRewrite to APISIX response-rewrite plugin. +func NewResponseRewriteHandler() PluginAnnotationsHandler { + return &responseRewrite{} +} + +func (r *responseRewrite) PluginName() string { + return "response-rewrite" +} + +func (r *responseRewrite) Handle(e annotations.Extractor) (any, error) { + if !e.GetBoolAnnotation(annotations.AnnotationsEnableResponseRewrite) { + return nil, nil + } + + plugin := &adctypes.ResponseRewriteConfig{ + BodyBase64: e.GetBoolAnnotation(annotations.AnnotationsResponseRewriteBodyBase64), + Body: e.GetStringAnnotation(annotations.AnnotationsResponseRewriteBody), + } + + // Parse status code, transformation fail defaults to 0 + if statusCodeStr := e.GetStringAnnotation(annotations.AnnotationsResponseRewriteStatusCode); statusCodeStr != "" { + if statusCode, err := strconv.Atoi(statusCodeStr); err == nil { + plugin.StatusCode = statusCode + } + } + + // Handle headers + addHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderAdd) + setHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderSet) + removeHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderRemove) + + if len(addHeaders) > 0 || len(setHeaders) > 0 || len(removeHeaders) > 0 { + headers := &adctypes.ResponseHeaders{ + Add: addHeaders, + Remove: removeHeaders, + } + + // Convert set headers from ["key:value", ...] to map[string]string + if len(setHeaders) > 0 { + headers.Set = make(map[string]string) + for _, header := range setHeaders { + if key, value, found := parseHeaderKeyValue(header); found { + headers.Set[key] = value + } + } + } + + plugin.Headers = headers + } + + return plugin, nil +} + +// parseHeaderKeyValue parses a header string in format "key:value" and returns key, value and success flag +func parseHeaderKeyValue(header string) (string, string, bool) { + for i := 0; i < len(header); i++ { + if header[i] == ':' { + return header[:i], header[i+1:], true + } + } + return "", "", false +} diff --git a/internal/adc/translator/annotations/plugins/response_rewrite_test.go b/internal/adc/translator/annotations/plugins/response_rewrite_test.go new file mode 100644 index 00000000..1f7521b7 --- /dev/null +++ b/internal/adc/translator/annotations/plugins/response_rewrite_test.go @@ -0,0 +1,139 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +func TestResponseRewriteHandler(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsEnableResponseRewrite: "true", + annotations.AnnotationsResponseRewriteStatusCode: "200", + annotations.AnnotationsResponseRewriteBody: "bar_body", + annotations.AnnotationsResponseRewriteBodyBase64: "false", + annotations.AnnotationsResponseRewriteHeaderAdd: "testkey1:testval1,testkey2:testval2", + annotations.AnnotationsResponseRewriteHeaderRemove: "testkey1,testkey2", + annotations.AnnotationsResponseRewriteHeaderSet: "testkey1:testval1,testkey2:testval2", + } + p := NewResponseRewriteHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config := out.(*adctypes.ResponseRewriteConfig) + assert.Equal(t, 200, config.StatusCode) + assert.Equal(t, "bar_body", config.Body) + assert.Equal(t, false, config.BodyBase64) + assert.Equal(t, "response-rewrite", p.PluginName()) + assert.Equal(t, []string{"testkey1:testval1", "testkey2:testval2"}, config.Headers.Add) + assert.Equal(t, []string{"testkey1", "testkey2"}, config.Headers.Remove) + assert.Equal(t, map[string]string{ + "testkey1": "testval1", + "testkey2": "testval2", + }, config.Headers.Set) +} + +func TestResponseRewriteHandlerDisabled(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsEnableResponseRewrite: "false", + annotations.AnnotationsResponseRewriteStatusCode: "400", + annotations.AnnotationsResponseRewriteBody: "bar_body", + } + p := NewResponseRewriteHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + assert.Nil(t, out, "checking given output") +} + +func TestResponseRewriteHandlerBase64(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsEnableResponseRewrite: "true", + annotations.AnnotationsResponseRewriteBody: "YmFyLWJvZHk=", + annotations.AnnotationsResponseRewriteBodyBase64: "true", + } + p := NewResponseRewriteHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config := out.(*adctypes.ResponseRewriteConfig) + assert.Equal(t, "YmFyLWJvZHk=", config.Body) + assert.Equal(t, true, config.BodyBase64) +} + +func TestResponseRewriteHandlerInvalidStatusCode(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsEnableResponseRewrite: "true", + annotations.AnnotationsResponseRewriteStatusCode: "invalid", + annotations.AnnotationsResponseRewriteBody: "bar_body", + } + p := NewResponseRewriteHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config := out.(*adctypes.ResponseRewriteConfig) + assert.Equal(t, 0, config.StatusCode, "invalid status code should default to 0") + assert.Equal(t, "bar_body", config.Body) +} + +func TestParseHeaderKeyValue(t *testing.T) { + tests := []struct { + name string + input string + wantKey string + wantValue string + wantFound bool + }{ + { + name: "valid header", + input: "Content-Type:application/json", + wantKey: "Content-Type", + wantValue: "application/json", + wantFound: true, + }, + { + name: "header with colon in value", + input: "X-Custom:value:with:colons", + wantKey: "X-Custom", + wantValue: "value:with:colons", + wantFound: true, + }, + { + name: "invalid header without colon", + input: "InvalidHeader", + wantKey: "", + wantValue: "", + wantFound: false, + }, + { + name: "empty value", + input: "X-Empty:", + wantKey: "X-Empty", + wantValue: "", + wantFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value, found := parseHeaderKeyValue(tt.input) + assert.Equal(t, tt.wantKey, key) + assert.Equal(t, tt.wantValue, value) + assert.Equal(t, tt.wantFound, found) + }) + } +} diff --git a/internal/webhook/v1/ingress_webhook.go b/internal/webhook/v1/ingress_webhook.go index 78bb4908..9620c845 100644 --- a/internal/webhook/v1/ingress_webhook.go +++ b/internal/webhook/v1/ingress_webhook.go @@ -43,13 +43,6 @@ var unsupportedAnnotations = []string{ "k8s.apisix.apache.org/rewrite-target", "k8s.apisix.apache.org/rewrite-target-regex", "k8s.apisix.apache.org/rewrite-target-regex-template", - "k8s.apisix.apache.org/enable-response-rewrite", - "k8s.apisix.apache.org/response-rewrite-status-code", - "k8s.apisix.apache.org/response-rewrite-body", - "k8s.apisix.apache.org/response-rewrite-body-base64", - "k8s.apisix.apache.org/response-rewrite-add-header", - "k8s.apisix.apache.org/response-rewrite-set-header", - "k8s.apisix.apache.org/response-rewrite-remove-header", "k8s.apisix.apache.org/auth-uri", "k8s.apisix.apache.org/auth-ssl-verify", "k8s.apisix.apache.org/auth-request-headers", diff --git a/test/e2e/ingress/annotations.go b/test/e2e/ingress/annotations.go index ad559528..8683935e 100644 --- a/test/e2e/ingress/annotations.go +++ b/test/e2e/ingress/annotations.go @@ -382,6 +382,57 @@ spec: name: httpbin-service-e2e-test port: number: 80 +` + responseRewrite = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: response-rewrite + annotations: + k8s.apisix.apache.org/enable-response-rewrite: "true" + k8s.apisix.apache.org/response-rewrite-status-code: "400" + k8s.apisix.apache.org/response-rewrite-body: "custom response body" + k8s.apisix.apache.org/response-rewrite-body-base64: "false" + k8s.apisix.apache.org/response-rewrite-set-header: "X-Custom-Header:custom-value" + k8s.apisix.apache.org/response-rewrite-add-header: "X-Add-Header:added-value" + k8s.apisix.apache.org/response-rewrite-remove-header: "Server" +spec: + ingressClassName: %s + rules: + - host: httpbin.example + http: + paths: + - path: /get + pathType: Exact + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 +` + responseRewriteBase64 = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: response-rewrite-base64 + annotations: + k8s.apisix.apache.org/enable-response-rewrite: "true" + k8s.apisix.apache.org/response-rewrite-status-code: "400" + k8s.apisix.apache.org/response-rewrite-body: "Y3VzdG9tIHJlc3BvbnNlIGJvZHk=" + k8s.apisix.apache.org/response-rewrite-body-base64: "true" +spec: + ingressClassName: %s + rules: + - host: httpbin-base64.example + http: + paths: + - path: /get + pathType: Exact + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 ` ) BeforeEach(func() { @@ -610,5 +661,62 @@ spec: s.RequestAssert(test) } }) + + It("response-rewrite", func() { + Expect(s.CreateResourceFromString(fmt.Sprintf(responseRewrite, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress") + + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin.example", + Checks: []scaffold.ResponseCheckFunc{ + scaffold.WithExpectedStatus(http.StatusBadRequest), + scaffold.WithExpectedBodyContains("custom response body"), + scaffold.WithExpectedHeader("X-Custom-Header", "custom-value"), + scaffold.WithExpectedHeader("X-Add-Header", "added-value"), + }, + }) + + By("Verify response-rewrite plugin is configured in the route") + routes, err := s.DefaultDataplaneResource().Route().List(context.Background()) + Expect(err).NotTo(HaveOccurred(), "listing Route") + Expect(routes).To(HaveLen(1), "checking Route length") + Expect(routes[0].Plugins).To(HaveKey("response-rewrite"), "checking Route plugins") + + jsonBytes, err := json.Marshal(routes[0].Plugins["response-rewrite"]) + Expect(err).NotTo(HaveOccurred(), "marshalling response-rewrite plugin config") + var rewriteConfig map[string]any + err = json.Unmarshal(jsonBytes, &rewriteConfig) + Expect(err).NotTo(HaveOccurred(), "unmarshalling response-rewrite plugin config") + Expect(rewriteConfig["status_code"]).To(Equal(float64(400)), "checking status code") + Expect(rewriteConfig["body"]).To(Equal("custom response body"), "checking body") + }) + + It("response-rewrite with base64", func() { + Expect(s.CreateResourceFromString(fmt.Sprintf(responseRewriteBase64, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress") + + s.RequestAssert(&scaffold.RequestAssert{ + Method: "GET", + Path: "/get", + Host: "httpbin-base64.example", + Checks: []scaffold.ResponseCheckFunc{ + scaffold.WithExpectedStatus(http.StatusBadRequest), + scaffold.WithExpectedBodyContains("custom response body"), + }, + }) + By("Verify response-rewrite plugin is configured in the route") + routes, err := s.DefaultDataplaneResource().Route().List(context.Background()) + Expect(err).NotTo(HaveOccurred(), "listing Route") + Expect(routes).To(HaveLen(1), "checking Route length") + Expect(routes[0].Plugins).To(HaveKey("response-rewrite"), "checking Route plugins") + + jsonBytes, err := json.Marshal(routes[0].Plugins["response-rewrite"]) + Expect(err).NotTo(HaveOccurred(), "marshalling response-rewrite plugin config") + var rewriteConfig map[string]any + err = json.Unmarshal(jsonBytes, &rewriteConfig) + Expect(err).NotTo(HaveOccurred(), "unmarshalling response-rewrite plugin config") + Expect(rewriteConfig["status_code"]).To(Equal(float64(400)), "checking status code") + Expect(rewriteConfig["body_base64"]).To(BeTrue(), "checking body_base64") + }) }) })
