diff --git a/docs/en/latest/concepts/annotations.md b/docs/en/latest/concepts/annotations.md index a3292b1ec1..ca479cfb00 100644 --- a/docs/en/latest/concepts/annotations.md +++ b/docs/en/latest/concepts/annotations.md @@ -157,6 +157,32 @@ spec: number: 80 ``` +### Rewrite Headers + +### Add header + +This annotation configures to append the new headers in the upstream request. + +```yaml +k8s.apisix.apache.org/rewrite-add-header: "testkey1:testval1,testkey2:testval2" +``` + +### Set header + +This annotation configures to rewrite the new headers in the upstream request. + +```yaml +k8s.apisix.apache.org/rewrite-set-header: "testkey1:testval1,testkey2:testval2" +``` + +### Remove header + +This annotation configures to remove headers in the upstream request. + +```yaml +k8s.apisix.apache.org/rewrite-remove-header: "testkey1,testkey2" +``` + ## HTTP to HTTPS This annotation is used to redirect HTTP requests to HTTPS with a `301` status code and with the same URI as the original request. diff --git a/pkg/providers/gateway/translation/gateway_httproute.go b/pkg/providers/gateway/translation/gateway_httproute.go index f697c55c67..4cf6917096 100644 --- a/pkg/providers/gateway/translation/gateway_httproute.go +++ b/pkg/providers/gateway/translation/gateway_httproute.go @@ -55,21 +55,28 @@ func (t *translator) generatePluginFromHTTPRequestHeaderFilter(plugins apisixv1. if reqHeaderModifier == nil { return } - headers := map[string]any{} // TODO: The current apisix plugin does not conform to the specification. - for _, header := range reqHeaderModifier.Add { - headers[string(header.Name)] = header.Value - } - for _, header := range reqHeaderModifier.Set { - headers[string(header.Name)] = header.Value - } - for _, header := range reqHeaderModifier.Remove { - headers[header] = "" - } + plugin := apisixv1.RewriteConfig{} + + if len(reqHeaderModifier.Add) > 0 || len(reqHeaderModifier.Set) > 0 || len(reqHeaderModifier.Remove) > 0 { + headers := apisixv1.RewriteConfigHeaders{} + + headers.Add = make(map[string]string, len(reqHeaderModifier.Add)) + for _, header := range reqHeaderModifier.Add { + headers.Add[string(header.Name)] = header.Value + } + + headers.Set = make(map[string]string, len(reqHeaderModifier.Set)) + for _, header := range reqHeaderModifier.Set { + headers.Set[string(header.Name)] = header.Value + } - plugins["proxy-rewrite"] = apisixv1.RewriteConfig{ - Headers: headers, + headers.Remove = reqHeaderModifier.Remove + + plugin.Headers = &headers } + + plugins["proxy-rewrite"] = plugin } func (t *translator) generatePluginFromHTTPRequestMirrorFilter(namespace string, plugins apisixv1.Plugins, reqMirror *gatewayv1beta1.HTTPRequestMirrorFilter) { diff --git a/pkg/providers/ingress/translation/annotations/plugins/response_rewrite.go b/pkg/providers/ingress/translation/annotations/plugins/response_rewrite.go index f03a1d9491..8329043dc3 100644 --- a/pkg/providers/ingress/translation/annotations/plugins/response_rewrite.go +++ b/pkg/providers/ingress/translation/annotations/plugins/response_rewrite.go @@ -42,10 +42,18 @@ func (c *responseRewrite) Handle(e annotations.Extractor) (interface{}, error) { plugin.StatusCode, _ = strconv.Atoi(e.GetStringAnnotation(annotations.AnnotationsResponseRewriteStatusCode)) plugin.Body = e.GetStringAnnotation(annotations.AnnotationsResponseRewriteBody) plugin.BodyBase64 = e.GetBoolAnnotation(annotations.AnnotationsResponseRewriteBodyBase64) - headers := make(apisixv1.Headers) - headers.Add(e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderAdd)) - headers.Set(e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderSet)) - headers.Remove(e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderRemove)) - plugin.Headers = headers + + headersToAdd := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderAdd) + headersToSet := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderSet) + headersToRemove := e.GetStringsAnnotation(annotations.AnnotationsResponseRewriteHeaderRemove) + + if len(headersToAdd) > 0 || len(headersToSet) > 0 || len(headersToRemove) > 0 { + headers := apisixv1.ResponseRewriteConfigHeaders{} + headers.SetAddHeaders(headersToAdd) + headers.SetSetHeaders(headersToSet) + headers.SetRemoveHeaders(headersToRemove) + plugin.Headers = &headers + } + return &plugin, nil } diff --git a/pkg/providers/ingress/translation/annotations/plugins/response_rewrite_test.go b/pkg/providers/ingress/translation/annotations/plugins/response_rewrite_test.go index 302f7a21d9..8f8d21afa9 100644 --- a/pkg/providers/ingress/translation/annotations/plugins/response_rewrite_test.go +++ b/pkg/providers/ingress/translation/annotations/plugins/response_rewrite_test.go @@ -25,13 +25,10 @@ import ( 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", + annotations.AnnotationsEnableResponseRewrite: "true", + annotations.AnnotationsResponseRewriteStatusCode: "200", + annotations.AnnotationsResponseRewriteBody: "bar_body", + annotations.AnnotationsResponseRewriteBodyBase64: "false", } p := NewResponseRewriteHandler() out, err := p.Handle(annotations.NewExtractor(anno)) @@ -41,12 +38,21 @@ func TestResponseRewriteHandler(t *testing.T) { 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.GetAddedHeaders()) + assert.Nil(t, config.Headers) + + anno[annotations.AnnotationsResponseRewriteHeaderAdd] = "testkey1:testval1,testkey2:testval2" + anno[annotations.AnnotationsResponseRewriteHeaderRemove] = "testkey1,testkey2" + anno[annotations.AnnotationsResponseRewriteHeaderSet] = "testkey1:testval1,testkey2:testval2" + out, err = p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config = out.(*apisixv1.ResponseRewriteConfig) + assert.Equal(t, []string{"testkey1:testval1", "testkey2:testval2"}, config.Headers.GetAddHeaders()) assert.Equal(t, []string{"testkey1", "testkey2"}, config.Headers.GetRemovedHeaders()) assert.Equal(t, map[string]string{ "testkey1": "testval1", "testkey2": "testval2", }, config.Headers.GetSetHeaders()) + anno[annotations.AnnotationsEnableResponseRewrite] = "false" out, err = p.Handle(annotations.NewExtractor(anno)) assert.Nil(t, err, "checking given error") diff --git a/pkg/providers/ingress/translation/annotations/plugins/rewrite.go b/pkg/providers/ingress/translation/annotations/plugins/rewrite.go index 406f87f05a..d3656a4981 100644 --- a/pkg/providers/ingress/translation/annotations/plugins/rewrite.go +++ b/pkg/providers/ingress/translation/annotations/plugins/rewrite.go @@ -38,7 +38,12 @@ func (i *rewrite) Handle(e annotations.Extractor) (interface{}, error) { rewriteTarget := e.GetStringAnnotation(annotations.AnnotationsRewriteTarget) rewriteTargetRegex := e.GetStringAnnotation(annotations.AnnotationsRewriteTargetRegex) rewriteTemplate := e.GetStringAnnotation(annotations.AnnotationsRewriteTargetRegexTemplate) - if rewriteTarget != "" || rewriteTargetRegex != "" || rewriteTemplate != "" { + + headersToAdd := e.GetStringsAnnotation(annotations.AnnotationsRewriteHeaderAdd) + headersToSet := e.GetStringsAnnotation(annotations.AnnotationsRewriteHeaderSet) + headersToRemove := e.GetStringsAnnotation(annotations.AnnotationsRewriteHeaderRemove) + + if rewriteTarget != "" || rewriteTargetRegex != "" || rewriteTemplate != "" || len(headersToAdd) > 0 || len(headersToSet) > 0 || len(headersToRemove) > 0 { plugin.RewriteTarget = rewriteTarget if rewriteTargetRegex != "" && rewriteTemplate != "" { _, err := regexp.Compile(rewriteTargetRegex) @@ -47,6 +52,13 @@ func (i *rewrite) Handle(e annotations.Extractor) (interface{}, error) { } plugin.RewriteTargetRegex = []string{rewriteTargetRegex, rewriteTemplate} } + if len(headersToAdd) > 0 || len(headersToSet) > 0 || len(headersToRemove) > 0 { + headers := apisixv1.RewriteConfigHeaders{} + headers.SetAddHeaders(headersToAdd) + headers.SetSetHeaders(headersToSet) + headers.SetRemoveHeaders(headersToRemove) + plugin.Headers = &headers + } return &plugin, nil } return nil, nil diff --git a/pkg/providers/ingress/translation/annotations/plugins/rewrite_test.go b/pkg/providers/ingress/translation/annotations/plugins/rewrite_test.go new file mode 100644 index 0000000000..d8bda67c16 --- /dev/null +++ b/pkg/providers/ingress/translation/annotations/plugins/rewrite_test.go @@ -0,0 +1,61 @@ +// 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" + + "github.com/apache/apisix-ingress-controller/pkg/providers/ingress/translation/annotations" + apisixv1 "github.com/apache/apisix-ingress-controller/pkg/types/apisix/v1" +) + +func TestRewriteHandler(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsRewriteTarget: "/sample", + annotations.AnnotationsRewriteTargetRegex: "/sample/(.*)", + annotations.AnnotationsRewriteTargetRegexTemplate: "/$1", + annotations.AnnotationsRewriteHeaderAdd: "testkey1:testval1,testkey2:testval2", + annotations.AnnotationsRewriteHeaderRemove: "testkey1,testkey2", + annotations.AnnotationsRewriteHeaderSet: "testsetkey1:testsetval1,testsetkey2:testsetval2", + } + p := NewRewriteHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config := out.(*apisixv1.RewriteConfig) + assert.Equal(t, "/sample", config.RewriteTarget) + assert.Equal(t, []string{"/sample/(.*)", "/$1"}, config.RewriteTargetRegex) + assert.Equal(t, "proxy-rewrite", p.PluginName()) + assert.Equal(t, map[string]string{ + "testkey1": "testval1", + "testkey2": "testval2", + }, config.Headers.GetAddHeaders()) + assert.Equal(t, []string{"testkey1", "testkey2"}, config.Headers.GetRemovedHeaders()) + assert.Equal(t, map[string]string{ + "testsetkey1": "testsetval1", + "testsetkey2": "testsetval2", + }, config.Headers.GetSetHeaders()) + + anno = map[string]string{ + annotations.AnnotationsRewriteTarget: "/sample", + } + out, err = p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config = out.(*apisixv1.RewriteConfig) + assert.Equal(t, "/sample", config.RewriteTarget) + assert.Nil(t, config.RewriteTargetRegex) + assert.Nil(t, config.Headers) +} diff --git a/pkg/providers/ingress/translation/annotations/types.go b/pkg/providers/ingress/translation/annotations/types.go index aac0a0baf4..9a5ba5d166 100644 --- a/pkg/providers/ingress/translation/annotations/types.go +++ b/pkg/providers/ingress/translation/annotations/types.go @@ -57,6 +57,9 @@ const ( AnnotationsRewriteTarget = AnnotationsPrefix + "rewrite-target" AnnotationsRewriteTargetRegex = AnnotationsPrefix + "rewrite-target-regex" AnnotationsRewriteTargetRegexTemplate = AnnotationsPrefix + "rewrite-target-regex-template" + AnnotationsRewriteHeaderAdd = AnnotationsPrefix + "rewrite-add-header" + AnnotationsRewriteHeaderSet = AnnotationsPrefix + "rewrite-set-header" + AnnotationsRewriteHeaderRemove = AnnotationsPrefix + "rewrite-remove-header" // response-rewrite plugin AnnotationsEnableResponseRewrite = AnnotationsPrefix + "enable-response-rewrite" diff --git a/pkg/types/apisix/v1/plugin_types.go b/pkg/types/apisix/v1/plugin_types.go index 51c1f6382d..3f09c08168 100644 --- a/pkg/types/apisix/v1/plugin_types.go +++ b/pkg/types/apisix/v1/plugin_types.go @@ -137,20 +137,20 @@ type WolfRBACConsumerConfig struct { // RewriteConfig is the rule config for proxy-rewrite plugin. // +k8s:deepcopy-gen=true type RewriteConfig struct { - RewriteTarget string `json:"uri,omitempty"` - RewriteTargetRegex []string `json:"regex_uri,omitempty"` - Headers Headers `json:"headers,omitempty"` + RewriteTarget string `json:"uri,omitempty"` + RewriteTargetRegex []string `json:"regex_uri,omitempty"` + Headers *RewriteConfigHeaders `json:"headers,omitempty"` } // ResponseRewriteConfig is the rule config for response-rewrite plugin. // +k8s:deepcopy-gen=true type ResponseRewriteConfig struct { - StatusCode int `json:"status_code,omitempty"` - Body string `json:"body,omitempty"` - BodyBase64 bool `json:"body_base64,omitempty"` - Headers Headers `json:"headers,omitempty"` - LuaRestyExpr []expr.Expr `json:"vars,omitempty"` - Filters []map[string]string `json:"filters,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Body string `json:"body,omitempty"` + BodyBase64 bool `json:"body_base64,omitempty"` + Headers *ResponseRewriteConfigHeaders `json:"headers,omitempty"` + LuaRestyExpr []expr.Expr `json:"vars,omitempty"` + Filters []map[string]string `json:"filters,omitempty"` } // RedirectConfig is the rule config for redirect plugin. @@ -187,23 +187,50 @@ type RequestMirror struct { Host string `json:"host"` } -type Headers map[string]any +type Headers struct { + Set map[string]string `json:"set,omitempty"` + Remove []string `json:"remove,omitempty"` +} + +type ResponseRewriteConfigHeaders struct { + Add []string `json:"add,omitempty"` + Headers +} + +type RewriteConfigHeaders struct { + Add map[string]string `json:"add,omitempty"` + Headers +} -func (p *Headers) DeepCopyInto(out *Headers) { +func (p *ResponseRewriteConfigHeaders) DeepCopyInto(out *ResponseRewriteConfigHeaders) { b, _ := json.Marshal(&p) _ = json.Unmarshal(b, out) } -func (p *Headers) DeepCopy() *Headers { +func (p *ResponseRewriteConfigHeaders) DeepCopy() *ResponseRewriteConfigHeaders { if p == nil { return nil } - out := new(Headers) + out := new(ResponseRewriteConfigHeaders) p.DeepCopyInto(out) return out } -func (p *Headers) Add(headersToAdd []string) { +func (p *RewriteConfigHeaders) DeepCopyInto(out *RewriteConfigHeaders) { + b, _ := json.Marshal(&p) + _ = json.Unmarshal(b, out) +} + +func (p *RewriteConfigHeaders) DeepCopy() *RewriteConfigHeaders { + if p == nil { + return nil + } + out := new(RewriteConfigHeaders) + p.DeepCopyInto(out) + return out +} + +func (p *ResponseRewriteConfigHeaders) SetAddHeaders(headersToAdd []string) { if p == nil { return } @@ -216,22 +243,42 @@ func (p *Headers) Add(headersToAdd []string) { } addedHeader = append(addedHeader, fmt.Sprintf("%s:%s", kv[0], kv[1])) } - (*p)["add"] = addedHeader + p.Add = addedHeader } } -func (p *Headers) GetAddedHeaders() []string { - if p == nil || (*p)["add"] == nil { +func (p *ResponseRewriteConfigHeaders) GetAddHeaders() []string { + if p == nil || p.Add == nil { return nil } - addedheaders, ok := (*p)["add"].([]string) - if ok { - return addedheaders + return p.Add +} + +func (p *RewriteConfigHeaders) SetAddHeaders(headersToAdd []string) { + if p == nil { + return + } + if headersToAdd != nil { + addedHeader := make(map[string]string, 0) + for _, h := range headersToAdd { + kv := strings.Split(h, ":") + if len(kv) < 2 { + continue + } + addedHeader[kv[0]] = kv[1] + } + p.Add = addedHeader + } +} + +func (p *RewriteConfigHeaders) GetAddHeaders() map[string]string { + if p == nil || p.Add == nil { + return nil } - return nil + return p.Add } -func (p *Headers) Set(headersToSet []string) { +func (p *Headers) SetSetHeaders(headersToSet []string) { if p == nil { return } @@ -244,39 +291,31 @@ func (p *Headers) Set(headersToSet []string) { } setHeaders[kv[0]] = kv[1] } - (*p)["set"] = setHeaders + p.Set = setHeaders } } func (p *Headers) GetSetHeaders() map[string]string { - if p == nil || (*p)["set"] == nil { + if p == nil || p.Set == nil { return nil } - addedheaders, ok := (*p)["set"].(map[string]string) - if ok { - return addedheaders - } - return nil + return p.Set } -func (p *Headers) Remove(headersToRemove []string) { +func (p *Headers) SetRemoveHeaders(headersToRemove []string) { if p == nil { return } if headersToRemove != nil { removeHeaders := make([]string, 0) removeHeaders = append(removeHeaders, headersToRemove...) - (*p)["remove"] = removeHeaders + p.Remove = removeHeaders } } func (p *Headers) GetRemovedHeaders() []string { - if p == nil || (*p)["remove"] == nil { + if p == nil || p.Remove == nil { return nil } - removedHeaders, ok := (*p)["remove"].([]string) - if ok { - return removedHeaders - } - return nil + return p.Remove } diff --git a/pkg/types/apisix/v1/zz_generated.deepcopy.go b/pkg/types/apisix/v1/zz_generated.deepcopy.go index 6910b5f421..7583af53d4 100644 --- a/pkg/types/apisix/v1/zz_generated.deepcopy.go +++ b/pkg/types/apisix/v1/zz_generated.deepcopy.go @@ -384,7 +384,10 @@ func (in *RequestMirror) DeepCopy() *RequestMirror { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResponseRewriteConfig) DeepCopyInto(out *ResponseRewriteConfig) { *out = *in - in.Headers.DeepCopyInto(&out.Headers) + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = (*in).DeepCopy() + } if in.LuaRestyExpr != nil { in, out := &in.LuaRestyExpr, &out.LuaRestyExpr *out = make([]expr.Expr, len(*in)) @@ -426,7 +429,10 @@ func (in *RewriteConfig) DeepCopyInto(out *RewriteConfig) { *out = make([]string, len(*in)) copy(*out, *in) } - in.Headers.DeepCopyInto(&out.Headers) + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = (*in).DeepCopy() + } return } diff --git a/test/e2e/suite-annotations/rewrite.go b/test/e2e/suite-annotations/rewrite.go index d3795600a5..d47a5cec50 100644 --- a/test/e2e/suite-annotations/rewrite.go +++ b/test/e2e/suite-annotations/rewrite.go @@ -154,3 +154,82 @@ spec: _ = s.NewAPISIXClient().GET("/sample/get").WithHeader("Host", "httpbin.org").Expect().Status(http.StatusOK) }) }) + +var _ = ginkgo.Describe("suite-annotations: rewrite header annotations", func() { + s := scaffold.NewDefaultScaffold() + + ginkgo.It("enable in ingress networking/v1", func() { + backendSvc, backendPort := s.DefaultHTTPBackend() + ing := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: apisix + k8s.apisix.apache.org/rewrite-target-regex: "/sample/(.*)" + k8s.apisix.apache.org/rewrite-target-regex-template: "/$1" + k8s.apisix.apache.org/rewrite-add-header: "X-Api-Version:v1,X-Api-Engine:Apisix" + k8s.apisix.apache.org/rewrite-set-header: "X-Api-Custom:extended" + k8s.apisix.apache.org/rewrite-remove-header: "X-Test" + name: ingress-v1 +spec: + rules: + - host: httpbin.org + http: + paths: + - path: /sample + pathType: Prefix + backend: + service: + name: %s + port: + number: %d +`, backendSvc, backendPort[0]) + err := s.CreateResourceFromString(ing) + assert.Nil(ginkgo.GinkgoT(), err, "creating ingress") + time.Sleep(5 * time.Second) + + resp := s.NewAPISIXClient().GET("/sample/get").WithHeader("Host", "httpbin.org").WithHeader("X-Api-Custom", "Basic").WithHeader("X-Test", "Test").Expect() + resp.Status(http.StatusOK) + resp.Body().Contains("\"X-Api-Version\": \"v1\"") + resp.Body().Contains("\"X-Api-Engine\": \"Apisix\"") + resp.Body().Contains("\"X-Api-Custom\": \"extended\"") + resp.Body().NotContains("\"X-Test\"") + }) + ginkgo.It("enable in ingress networking/v1beta1", func() { + backendSvc, backendPort := s.DefaultHTTPBackend() + ing := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: apisix + k8s.apisix.apache.org/rewrite-target-regex: "/sample/(.*)" + k8s.apisix.apache.org/rewrite-target-regex-template: "/$1" + k8s.apisix.apache.org/rewrite-add-header: "X-Api-Version:v1,X-Api-Engine:Apisix" + k8s.apisix.apache.org/rewrite-set-header: "X-Api-Custom:extended" + k8s.apisix.apache.org/rewrite-remove-header: "X-Test" + name: ingress-v1beta1 +spec: + rules: + - host: httpbin.org + http: + paths: + - path: /sample + pathType: Prefix + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPort[0]) + err := s.CreateResourceFromString(ing) + assert.Nil(ginkgo.GinkgoT(), err, "creating ingress") + time.Sleep(5 * time.Second) + + resp := s.NewAPISIXClient().GET("/sample/get").WithHeader("Host", "httpbin.org").WithHeader("X-Api-Custom", "Basic").WithHeader("X-Test", "Test").Expect() + resp.Status(http.StatusOK) + resp.Body().Contains("\"X-Api-Version\": \"v1\"") + resp.Body().Contains("\"X-Api-Engine\": \"Apisix\"") + resp.Body().Contains("\"X-Api-Custom\": \"extended\"") + resp.Body().NotContains("\"X-Test\"") + }) +})