Skip to content

Commit 9be64b6

Browse files
authored
feat: support response rewrite annotations for ingress (#2638)
1 parent d1abfd5 commit 9be64b6

File tree

5 files changed

+337
-7
lines changed

5 files changed

+337
-7
lines changed

internal/adc/translator/annotations/plugins/plugins.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var (
4343
NewFaultInjectionHandler(),
4444
NewBasicAuthHandler(),
4545
NewKeyAuthHandler(),
46+
NewResponseRewriteHandler(),
4647
}
4748
)
4849

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package plugins
17+
18+
import (
19+
"strconv"
20+
21+
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
22+
"github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations"
23+
)
24+
25+
type responseRewrite struct{}
26+
27+
// NewResponseRewriteHandler creates a handler to convert annotations about
28+
// ResponseRewrite to APISIX response-rewrite plugin.
29+
func NewResponseRewriteHandler() PluginAnnotationsHandler {
30+
return &responseRewrite{}
31+
}
32+
33+
func (r *responseRewrite) PluginName() string {
34+
return "response-rewrite"
35+
}
36+
37+
func (r *responseRewrite) Handle(e annotations.Extractor) (any, error) {
38+
if !e.GetBoolAnnotation(annotations.AnnotationsEnableResponseRewrite) {
39+
return nil, nil
40+
}
41+
42+
plugin := &adctypes.ResponseRewriteConfig{
43+
BodyBase64: e.GetBoolAnnotation(annotations.AnnotationsResponseRewriteBodyBase64),
44+
Body: e.GetStringAnnotation(annotations.AnnotationsResponseRewriteBody),
45+
}
46+
47+
// Parse status code, transformation fail defaults to 0
48+
if statusCodeStr := e.GetStringAnnotation(annotations.AnnotationsResponseRewriteStatusCode); statusCodeStr != "" {
49+
if statusCode, err := strconv.Atoi(statusCodeStr); err == nil {
50+
plugin.StatusCode = statusCode
51+
}
52+
}
53+
54+
// Handle headers
55+
addHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderAdd)
56+
setHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderSet)
57+
removeHeaders := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderRemove)
58+
59+
if len(addHeaders) > 0 || len(setHeaders) > 0 || len(removeHeaders) > 0 {
60+
headers := &adctypes.ResponseHeaders{
61+
Add: addHeaders,
62+
Remove: removeHeaders,
63+
}
64+
65+
// Convert set headers from ["key:value", ...] to map[string]string
66+
if len(setHeaders) > 0 {
67+
headers.Set = make(map[string]string)
68+
for _, header := range setHeaders {
69+
if key, value, found := parseHeaderKeyValue(header); found {
70+
headers.Set[key] = value
71+
}
72+
}
73+
}
74+
75+
plugin.Headers = headers
76+
}
77+
78+
return plugin, nil
79+
}
80+
81+
// parseHeaderKeyValue parses a header string in format "key:value" and returns key, value and success flag
82+
func parseHeaderKeyValue(header string) (string, string, bool) {
83+
for i := 0; i < len(header); i++ {
84+
if header[i] == ':' {
85+
return header[:i], header[i+1:], true
86+
}
87+
}
88+
return "", "", false
89+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package plugins
17+
18+
import (
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
23+
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
24+
"github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations"
25+
)
26+
27+
func TestResponseRewriteHandler(t *testing.T) {
28+
anno := map[string]string{
29+
annotations.AnnotationsEnableResponseRewrite: "true",
30+
annotations.AnnotationsResponseRewriteStatusCode: "200",
31+
annotations.AnnotationsResponseRewriteBody: "bar_body",
32+
annotations.AnnotationsResponseRewriteBodyBase64: "false",
33+
annotations.AnnotationsResponseRewriteHeaderAdd: "testkey1:testval1,testkey2:testval2",
34+
annotations.AnnotationsResponseRewriteHeaderRemove: "testkey1,testkey2",
35+
annotations.AnnotationsResponseRewriteHeaderSet: "testkey1:testval1,testkey2:testval2",
36+
}
37+
p := NewResponseRewriteHandler()
38+
out, err := p.Handle(annotations.NewExtractor(anno))
39+
assert.Nil(t, err, "checking given error")
40+
config := out.(*adctypes.ResponseRewriteConfig)
41+
assert.Equal(t, 200, config.StatusCode)
42+
assert.Equal(t, "bar_body", config.Body)
43+
assert.Equal(t, false, config.BodyBase64)
44+
assert.Equal(t, "response-rewrite", p.PluginName())
45+
assert.Equal(t, []string{"testkey1:testval1", "testkey2:testval2"}, config.Headers.Add)
46+
assert.Equal(t, []string{"testkey1", "testkey2"}, config.Headers.Remove)
47+
assert.Equal(t, map[string]string{
48+
"testkey1": "testval1",
49+
"testkey2": "testval2",
50+
}, config.Headers.Set)
51+
}
52+
53+
func TestResponseRewriteHandlerDisabled(t *testing.T) {
54+
anno := map[string]string{
55+
annotations.AnnotationsEnableResponseRewrite: "false",
56+
annotations.AnnotationsResponseRewriteStatusCode: "400",
57+
annotations.AnnotationsResponseRewriteBody: "bar_body",
58+
}
59+
p := NewResponseRewriteHandler()
60+
out, err := p.Handle(annotations.NewExtractor(anno))
61+
assert.Nil(t, err, "checking given error")
62+
assert.Nil(t, out, "checking given output")
63+
}
64+
65+
func TestResponseRewriteHandlerBase64(t *testing.T) {
66+
anno := map[string]string{
67+
annotations.AnnotationsEnableResponseRewrite: "true",
68+
annotations.AnnotationsResponseRewriteBody: "YmFyLWJvZHk=",
69+
annotations.AnnotationsResponseRewriteBodyBase64: "true",
70+
}
71+
p := NewResponseRewriteHandler()
72+
out, err := p.Handle(annotations.NewExtractor(anno))
73+
assert.Nil(t, err, "checking given error")
74+
config := out.(*adctypes.ResponseRewriteConfig)
75+
assert.Equal(t, "YmFyLWJvZHk=", config.Body)
76+
assert.Equal(t, true, config.BodyBase64)
77+
}
78+
79+
func TestResponseRewriteHandlerInvalidStatusCode(t *testing.T) {
80+
anno := map[string]string{
81+
annotations.AnnotationsEnableResponseRewrite: "true",
82+
annotations.AnnotationsResponseRewriteStatusCode: "invalid",
83+
annotations.AnnotationsResponseRewriteBody: "bar_body",
84+
}
85+
p := NewResponseRewriteHandler()
86+
out, err := p.Handle(annotations.NewExtractor(anno))
87+
assert.Nil(t, err, "checking given error")
88+
config := out.(*adctypes.ResponseRewriteConfig)
89+
assert.Equal(t, 0, config.StatusCode, "invalid status code should default to 0")
90+
assert.Equal(t, "bar_body", config.Body)
91+
}
92+
93+
func TestParseHeaderKeyValue(t *testing.T) {
94+
tests := []struct {
95+
name string
96+
input string
97+
wantKey string
98+
wantValue string
99+
wantFound bool
100+
}{
101+
{
102+
name: "valid header",
103+
input: "Content-Type:application/json",
104+
wantKey: "Content-Type",
105+
wantValue: "application/json",
106+
wantFound: true,
107+
},
108+
{
109+
name: "header with colon in value",
110+
input: "X-Custom:value:with:colons",
111+
wantKey: "X-Custom",
112+
wantValue: "value:with:colons",
113+
wantFound: true,
114+
},
115+
{
116+
name: "invalid header without colon",
117+
input: "InvalidHeader",
118+
wantKey: "",
119+
wantValue: "",
120+
wantFound: false,
121+
},
122+
{
123+
name: "empty value",
124+
input: "X-Empty:",
125+
wantKey: "X-Empty",
126+
wantValue: "",
127+
wantFound: true,
128+
},
129+
}
130+
131+
for _, tt := range tests {
132+
t.Run(tt.name, func(t *testing.T) {
133+
key, value, found := parseHeaderKeyValue(tt.input)
134+
assert.Equal(t, tt.wantKey, key)
135+
assert.Equal(t, tt.wantValue, value)
136+
assert.Equal(t, tt.wantFound, found)
137+
})
138+
}
139+
}

internal/webhook/v1/ingress_webhook.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,6 @@ var ingresslog = logf.Log.WithName("ingress-resource")
4040
// ref: https://apisix.apache.org/docs/ingress-controller/upgrade-guide/#limited-support-for-ingress-annotations
4141
var unsupportedAnnotations = []string{
4242
"k8s.apisix.apache.org/use-regex",
43-
"k8s.apisix.apache.org/enable-response-rewrite",
44-
"k8s.apisix.apache.org/response-rewrite-status-code",
45-
"k8s.apisix.apache.org/response-rewrite-body",
46-
"k8s.apisix.apache.org/response-rewrite-body-base64",
47-
"k8s.apisix.apache.org/response-rewrite-add-header",
48-
"k8s.apisix.apache.org/response-rewrite-set-header",
49-
"k8s.apisix.apache.org/response-rewrite-remove-header",
5043
"k8s.apisix.apache.org/auth-uri",
5144
"k8s.apisix.apache.org/auth-ssl-verify",
5245
"k8s.apisix.apache.org/auth-request-headers",

test/e2e/ingress/annotations.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,57 @@ spec:
478478
name: httpbin-service-e2e-test
479479
port:
480480
number: 80
481+
`
482+
responseRewrite = `
483+
apiVersion: networking.k8s.io/v1
484+
kind: Ingress
485+
metadata:
486+
name: response-rewrite
487+
annotations:
488+
k8s.apisix.apache.org/enable-response-rewrite: "true"
489+
k8s.apisix.apache.org/response-rewrite-status-code: "400"
490+
k8s.apisix.apache.org/response-rewrite-body: "custom response body"
491+
k8s.apisix.apache.org/response-rewrite-body-base64: "false"
492+
k8s.apisix.apache.org/response-rewrite-set-header: "X-Custom-Header:custom-value"
493+
k8s.apisix.apache.org/response-rewrite-add-header: "X-Add-Header:added-value"
494+
k8s.apisix.apache.org/response-rewrite-remove-header: "Server"
495+
spec:
496+
ingressClassName: %s
497+
rules:
498+
- host: httpbin.example
499+
http:
500+
paths:
501+
- path: /get
502+
pathType: Exact
503+
backend:
504+
service:
505+
name: httpbin-service-e2e-test
506+
port:
507+
number: 80
508+
`
509+
responseRewriteBase64 = `
510+
apiVersion: networking.k8s.io/v1
511+
kind: Ingress
512+
metadata:
513+
name: response-rewrite-base64
514+
annotations:
515+
k8s.apisix.apache.org/enable-response-rewrite: "true"
516+
k8s.apisix.apache.org/response-rewrite-status-code: "400"
517+
k8s.apisix.apache.org/response-rewrite-body: "Y3VzdG9tIHJlc3BvbnNlIGJvZHk="
518+
k8s.apisix.apache.org/response-rewrite-body-base64: "true"
519+
spec:
520+
ingressClassName: %s
521+
rules:
522+
- host: httpbin-base64.example
523+
http:
524+
paths:
525+
- path: /get
526+
pathType: Exact
527+
backend:
528+
service:
529+
name: httpbin-service-e2e-test
530+
port:
531+
number: 80
481532
`
482533
)
483534
BeforeEach(func() {
@@ -843,5 +894,62 @@ spec:
843894
Expect(regexUri[0]).To(Equal("/sample/(.*)"), "checking regex pattern")
844895
Expect(regexUri[1]).To(Equal("/$1"), "checking regex template")
845896
})
897+
898+
It("response-rewrite", func() {
899+
Expect(s.CreateResourceFromString(fmt.Sprintf(responseRewrite, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress")
900+
901+
s.RequestAssert(&scaffold.RequestAssert{
902+
Method: "GET",
903+
Path: "/get",
904+
Host: "httpbin.example",
905+
Checks: []scaffold.ResponseCheckFunc{
906+
scaffold.WithExpectedStatus(http.StatusBadRequest),
907+
scaffold.WithExpectedBodyContains("custom response body"),
908+
scaffold.WithExpectedHeader("X-Custom-Header", "custom-value"),
909+
scaffold.WithExpectedHeader("X-Add-Header", "added-value"),
910+
},
911+
})
912+
913+
By("Verify response-rewrite plugin is configured in the route")
914+
routes, err := s.DefaultDataplaneResource().Route().List(context.Background())
915+
Expect(err).NotTo(HaveOccurred(), "listing Route")
916+
Expect(routes).To(HaveLen(1), "checking Route length")
917+
Expect(routes[0].Plugins).To(HaveKey("response-rewrite"), "checking Route plugins")
918+
919+
jsonBytes, err := json.Marshal(routes[0].Plugins["response-rewrite"])
920+
Expect(err).NotTo(HaveOccurred(), "marshalling response-rewrite plugin config")
921+
var rewriteConfig map[string]any
922+
err = json.Unmarshal(jsonBytes, &rewriteConfig)
923+
Expect(err).NotTo(HaveOccurred(), "unmarshalling response-rewrite plugin config")
924+
Expect(rewriteConfig["status_code"]).To(Equal(float64(400)), "checking status code")
925+
Expect(rewriteConfig["body"]).To(Equal("custom response body"), "checking body")
926+
})
927+
928+
It("response-rewrite with base64", func() {
929+
Expect(s.CreateResourceFromString(fmt.Sprintf(responseRewriteBase64, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress")
930+
931+
s.RequestAssert(&scaffold.RequestAssert{
932+
Method: "GET",
933+
Path: "/get",
934+
Host: "httpbin-base64.example",
935+
Checks: []scaffold.ResponseCheckFunc{
936+
scaffold.WithExpectedStatus(http.StatusBadRequest),
937+
scaffold.WithExpectedBodyContains("custom response body"),
938+
},
939+
})
940+
By("Verify response-rewrite plugin is configured in the route")
941+
routes, err := s.DefaultDataplaneResource().Route().List(context.Background())
942+
Expect(err).NotTo(HaveOccurred(), "listing Route")
943+
Expect(routes).To(HaveLen(1), "checking Route length")
944+
Expect(routes[0].Plugins).To(HaveKey("response-rewrite"), "checking Route plugins")
945+
946+
jsonBytes, err := json.Marshal(routes[0].Plugins["response-rewrite"])
947+
Expect(err).NotTo(HaveOccurred(), "marshalling response-rewrite plugin config")
948+
var rewriteConfig map[string]any
949+
err = json.Unmarshal(jsonBytes, &rewriteConfig)
950+
Expect(err).NotTo(HaveOccurred(), "unmarshalling response-rewrite plugin config")
951+
Expect(rewriteConfig["status_code"]).To(Equal(float64(400)), "checking status code")
952+
Expect(rewriteConfig["body_base64"]).To(BeTrue(), "checking body_base64")
953+
})
846954
})
847955
})

0 commit comments

Comments
 (0)