feat: support response rewrite annotations for ingress (#2638)

diff --git a/internal/adc/translator/annotations/plugins/plugins.go b/internal/adc/translator/annotations/plugins/plugins.go
index a8e7123..06bc87f 100644
--- a/internal/adc/translator/annotations/plugins/plugins.go
+++ b/internal/adc/translator/annotations/plugins/plugins.go
@@ -43,6 +43,7 @@
 		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 0000000..7de6223
--- /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 0000000..1f7521b
--- /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 6f72451..2099621 100644
--- a/internal/webhook/v1/ingress_webhook.go
+++ b/internal/webhook/v1/ingress_webhook.go
@@ -40,13 +40,6 @@
 // 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 30b8ee7..98a0bca 100644
--- a/test/e2e/ingress/annotations.go
+++ b/test/e2e/ingress/annotations.go
@@ -479,6 +479,57 @@
             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() {
 			By("create GatewayProxy")
@@ -843,5 +894,62 @@
 			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")
+		})
 	})
 })