This is an automated email from the ASF dual-hosted git repository.
ronething pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new 9be64b6a feat: support response rewrite annotations for ingress (#2638)
9be64b6a is described below
commit 9be64b6abf273e3912f708012e712236050dd5fd
Author: Ashing Zheng <[email protected]>
AuthorDate: Thu Oct 30 16:30:14 2025 +0800
feat: support response rewrite annotations for ingress (#2638)
---
.../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 a8e71239..06bc87f4 100644
--- a/internal/adc/translator/annotations/plugins/plugins.go
+++ b/internal/adc/translator/annotations/plugins/plugins.go
@@ -43,6 +43,7 @@ var (
NewFaultInjectionHandler(),
NewBasicAuthHandler(),
NewKeyAuthHandler(),
+ 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 6f72451f..2099621c 100644
--- a/internal/webhook/v1/ingress_webhook.go
+++ b/internal/webhook/v1/ingress_webhook.go
@@ -40,13 +40,6 @@ var ingresslog = logf.Log.WithName("ingress-resource")
// ref:
https://apisix.apache.org/docs/ingress-controller/upgrade-guide/#limited-support-for-ingress-annotations
var unsupportedAnnotations = []string{
"k8s.apisix.apache.org/use-regex",
- "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 30b8ee75..98a0bcaf 100644
--- a/test/e2e/ingress/annotations.go
+++ b/test/e2e/ingress/annotations.go
@@ -478,6 +478,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() {
@@ -843,5 +894,62 @@ spec:
Expect(regexUri[0]).To(Equal("/sample/(.*)"), "checking
regex pattern")
Expect(regexUri[1]).To(Equal("/$1"), "checking regex
template")
})
+
+ 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")
+ })
})
})