diff --git a/docs/en/latest/concepts/apisix_route.md b/docs/en/latest/concepts/apisix_route.md index 1e678ca286..52a9a7e469 100644 --- a/docs/en/latest/concepts/apisix_route.md +++ b/docs/en/latest/concepts/apisix_route.md @@ -90,6 +90,33 @@ spec: servicePort: 80 ``` +The `nginxVars` allows user to configure match conditions with arbitrary predicates in HTTP, such as queries, HTTP headers and etc. +It's composed by several expressions, which in turn composed by subject, operator and value/set. + +```yaml +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: method-route +spec: + http: + - name: method + match: + paths: + - / + nginxVars: + - subject: arg_id + op: Equal + value: 2143 + backend: + serviceName: foo + servicePort: 80 +``` + +The above configuration configures an extra route match condition, which asks the +query `id` must be equal to `2143`. Note the subject is in [Nginx Variables](http://nginx.org/en/docs/varindex.html) style +(but without the leading `$` symbol). + Service Resolution Granularity ------------------------------ diff --git a/docs/en/latest/references/apisix_route_v2alpha1.md b/docs/en/latest/references/apisix_route_v2alpha1.md index af36626bec..40795c55d4 100644 --- a/docs/en/latest/references/apisix_route_v2alpha1.md +++ b/docs/en/latest/references/apisix_route_v2alpha1.md @@ -20,3 +20,55 @@ title: ApisixRoute/v2alpha1 Reference # limitations under the License. # --> + +## Spec + +Meaning of each field in the spec of ApisixRoute are followed, the top level fields (`apiVersion`, `kind` and `metadata`) are same as other Kubernetes Resources. + +| Field | Type | Description | +|---------------|----------|----------------------------------------------------| +| http | array | ApisixRoute's HTTP route rules. | +| http[].name | string (required) | The route rule name. | +| http[].priority | integer | The route priority, it's used to determine which route will be hitted when multile routes contains the same URI. Large number means higher priority. | +| http[].match | object | Route match conditions. | +| http[].match.paths | array | A series of URI that should be matched (oneof) to use this route rule. | +| http[].match.hosts | array | A series of hosts that should be matched (oneof) to use this route rule. +| http[].match.methods | array | A series of HTTP methods(`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`) that should be matched (oneof) to use this route rule. +| http[].match.remote_addrs | array | A series of IP address (CIDR format) that should be matched (oneof) to use this route rule. +| http[].match.nginxVars | array | A series expressions that the results should be matched (oneof) to use this route rule. +| http[].match.nginxVars[].subject | string | Expression subject, which is in [Nginx Variables](http://nginx.org/en/docs/varindex.html) style. +| http[].match.nginxVars[].op | string | Expression operator, see [Expression Operators](#expression-operators) for the detail of enumerations. +| http[].match.nginxVars[].value | string | Expected expression result, it's exclusive with `http[].match.nginxVars[].set`. +| http[].match.nginxVars[].set | array | Expected expression result set, only used when the operator is `In` or `NotIn`, it's exclusive with `http[].match.nginxVars[].value`. +| http[].backend | object | The backend service +| http[].backend.serviceName | string | The backend service name, note the service and ApisixRoute should be created in the same namespace. Cross namespace referencing is not allowed. +| http[].backend.servicePort | integer or string | The backend service port, can be the port number of the name defined in the service object. +| http[].backend.resolveGranualrity | string | See [Service Resolve Granularity](#service-resolve-granularity) for the details. +| http[].plugins | array | A series of APISIX plugins that will be executed once this route rule is matched | +| http[].plugins[].name | string | The plugin name, see [docs](http://apisix.apache.org/docs/apisix/getting-started) for learning the available plugins. +| http[].plugins[].enable | boolean | Whether the plugin is in use | +| http[].plugins[].config | object | The plugin configuration, fields should be same as in APISIX. | + +## Expression Operators + +| Operator | Meaning | +|----------|---------| +| Equal| The result of `subject` should be equal to the `value` | +| NotEqual | The result of `subject` should not be equal to `value` | +| GreaterThan | The result of `subject` should be a number and it must larger then `value`. | +| LessThan | The result of `subject` should be a number and it must less than `value`. | +| In | The result of `subject` should be inside the `set`. | +| NotIn | The result of `subject` should not be inside the `set`. | +| RegexMatch | The result of `subject` should be matched by the `value` (a PCRE regex pattern). | +| RegexNotMatch | The result of `subject` should not be matched by the `value` (a PCRE regex pattern). | +| RegexMatchCaseInsensitive | Similar with `RegexMatch` but the match process is case insensitive | +| RegexNotMatchCaseInsensitive | Similar with `RegexNotMatchCaseInsensitive` but the match process is case insensitive. | + +## Service Resolve Granularity + +The service resolve granularity determines whether the [Serivce ClusterIP](https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types) or its endpoints should be filled in the target upstream of APISIX. + +| Granularity | Meaning | +| ----------- | ------- | +| endpoint | Filled upstream nodes by Pods' IP. +| service | Filled upstream nodes by Service ClusterIP, in such a case, loadbalacing are implemented by [kube-proxy](https://kubernetes.io/docs/concepts/overview/components/#kube-proxy).| diff --git a/pkg/apisix/resource.go b/pkg/apisix/resource.go index 12245abcd8..7d18494f3a 100644 --- a/pkg/apisix/resource.go +++ b/pkg/apisix/resource.go @@ -77,6 +77,7 @@ type routeItem struct { ServiceId string `json:"service_id"` Host string `json:"host"` URI string `json:"uri"` + Vars [][]v1.StringOrSlice `json:"vars"` Uris []string `json:"uris"` Desc string `json:"desc"` Methods []string `json:"methods"` @@ -108,6 +109,7 @@ func (i *item) route(clusterName string) (*v1.Route, error) { Host: route.Host, Path: route.URI, Uris: route.Uris, + Vars: route.Vars, Methods: route.Methods, UpstreamId: route.UpstreamId, ServiceId: route.ServiceId, diff --git a/pkg/apisix/route.go b/pkg/apisix/route.go index 944c7f3e6a..2da945dcce 100644 --- a/pkg/apisix/route.go +++ b/pkg/apisix/route.go @@ -29,13 +29,14 @@ import ( ) type routeReqBody struct { - Desc string `json:"desc,omitempty"` - URI string `json:"uri,omitempty"` - Uris []string `json:"uris,omitempty"` - Host string `json:"host,omitempty"` - ServiceId string `json:"service_id,omitempty"` - UpstreamId string `json:"upstream_id,omitempty"` - Plugins v1.Plugins `json:"plugins,omitempty"` + Desc string `json:"desc,omitempty"` + URI string `json:"uri,omitempty"` + Uris []string `json:"uris,omitempty"` + Vars [][]v1.StringOrSlice `json:"vars,omitempty"` + Host string `json:"host,omitempty"` + ServiceId string `json:"service_id,omitempty"` + UpstreamId string `json:"upstream_id,omitempty"` + Plugins v1.Plugins `json:"plugins,omitempty"` } type routeClient struct { @@ -165,6 +166,7 @@ func (r *routeClient) Create(ctx context.Context, obj *v1.Route) (*v1.Route, err UpstreamId: obj.UpstreamId, Uris: obj.Uris, Plugins: obj.Plugins, + Vars: obj.Vars, }) if err != nil { return nil, err @@ -230,6 +232,7 @@ func (r *routeClient) Update(ctx context.Context, obj *v1.Route) (*v1.Route, err URI: obj.Path, ServiceId: obj.ServiceId, Plugins: obj.Plugins, + Vars: obj.Vars, }) if err != nil { return nil, err diff --git a/pkg/kube/apisix/apis/config/v2alpha1/types.go b/pkg/kube/apisix/apis/config/v2alpha1/types.go index 4e5ab9497d..64c009b443 100644 --- a/pkg/kube/apisix/apis/config/v2alpha1/types.go +++ b/pkg/kube/apisix/apis/config/v2alpha1/types.go @@ -24,6 +24,33 @@ import ( // +genclient // +genclient:noStatus +const ( + // OpEqual means the equal ("==") operator in nginxVars. + OpEqual = "Equal" + // OpNotEqual means the not equal ("~=") operator in nginxVars. + OpNotEqual = "NotEqual" + // OpGreaterThan means the greater than (">") operator in nginxVars. + OpGreaterThan = "GreaterThan" + // OpGreaterThanEqual means the greater than (">=") operator in nginxVars. + OpGreaterThanEqual = "GreaterThanEqual" + // OpLessThan means the less than ("<") operator in nginxVars. + OpLessThan = "LessThan" + // OpLessThanEqual means the less than equal ("<=") operator in nginxVars. + OpLessThanEqual = "LessThanEqual" + // OpRegexMatch means the regex match ("~~") operator in nginxVars. + OpRegexMatch = "RegexMatch" + // OpRegexNotMatch means the regex not match ("!~~") operator in nginxVars. + OpRegexNotMatch = "RegexNotMatch" + // OpRegexMatchCaseInsensitive means the regex match "~*" (case insensitive mode) operator in nginxVars. + OpRegexMatchCaseInsensitive = "RegexMatchCaseInsensitive" + // OpRegexNotMatchCaseInsensitive means the regex not match "!~*" (case insensitive mode) operator in nginxVars. + OpRegexNotMatchCaseInsensitive = "RegexNotMatchCaseInsensitive" + // OpIn means the in operator ("in") in nginxVars. + OpIn = "In" + // OpNotIn means the not in operator ("not_in") in nginxVars. + OpNotIn = "NotIn" +) + // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // ApisixRoute is used to define the route rules and upstreams for Apache APISIX. type ApisixRoute struct { @@ -86,28 +113,14 @@ type ApisixRouteHTTPMatchNginxVar struct { // be any string composed by literals and nginx // vars. Subject string `json:"subject"` - // Op is the operator, can be: - // - "==": equal - // - "~=": not equal - // - ">": greater than - // - ">=": greater than or equal to - // - "<": less then - // - "<=": less then or equal to - // - "~~": regex match - // - "!~~": regex not match - // - "~*": case insensitive regex match - // - "!~*": case insensitive regex not match - // - "in": set match, Value should be a string array. - // - "not_in": set match, Value should be a string array. - // - "contain": subject contains the Value (inclusion relation). - // - "not_contain": subject doesn't contain the Value (inclusion relation). + // Op is the operator. Op string `json:"op"` - // Array is an array type object of the expression. + // Set is an array type object of the expression. // It should be used when the Op is "in" or "not_in"; - Array []string `json:"array"` + Set []string `json:"set"` // Value is the normal type object for the expression, // it should be used when the Op is not "in" and "not_in". - // Array and Value are exclusive so only of them can be set + // Set and Value are exclusive so only of them can be set // in the same time. Value *string `json:"value"` } diff --git a/pkg/kube/apisix/apis/config/v2alpha1/zz_generated.deepcopy.go b/pkg/kube/apisix/apis/config/v2alpha1/zz_generated.deepcopy.go index 5fd71bf458..7dc2f84a0e 100644 --- a/pkg/kube/apisix/apis/config/v2alpha1/zz_generated.deepcopy.go +++ b/pkg/kube/apisix/apis/config/v2alpha1/zz_generated.deepcopy.go @@ -155,8 +155,8 @@ func (in *ApisixRouteHTTPMatch) DeepCopy() *ApisixRouteHTTPMatch { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApisixRouteHTTPMatchNginxVar) DeepCopyInto(out *ApisixRouteHTTPMatchNginxVar) { *out = *in - if in.Array != nil { - in, out := &in.Array, &out.Array + if in.Set != nil { + in, out := &in.Set, &out.Set *out = make([]string, len(*in)) copy(*out, *in) } diff --git a/pkg/kube/translation/apisix_route.go b/pkg/kube/translation/apisix_route.go index ab61048c47..d9f1bea62c 100644 --- a/pkg/kube/translation/apisix_route.go +++ b/pkg/kube/translation/apisix_route.go @@ -94,6 +94,7 @@ func (t *translator) TranslateRouteV1(ar *configv1.ApisixRoute) ([]*apisixv1.Rou func (t *translator) TranslateRouteV2alpha1(ar *configv2alpha1.ApisixRoute) ([]*apisixv1.Route, []*apisixv1.Upstream, error) { var ( + vars [][]apisixv1.StringOrSlice routes []*apisixv1.Route upstreams []*apisixv1.Upstream ) @@ -136,7 +137,7 @@ func (t *translator) TranslateRouteV2alpha1(ar *configv2alpha1.ApisixRoute) ([]* } if part.Backend.ResolveGranularity == "service" && svc.Spec.ClusterIP == "" { - log.Errorw("ApisixRoute refers to a headless service but want to use the service level resolve granualrity", + log.Errorw("ApisixRoute refers to a headless service but want to use the service level resolve granularity", zap.Any("ApisixRoute", ar), zap.Any("service", svc), ) @@ -155,6 +156,16 @@ func (t *translator) TranslateRouteV2alpha1(ar *configv2alpha1.ApisixRoute) ([]* pluginMap[plugin.Name] = make(map[string]interface{}) } } + if part.Match.NginxVars != nil { + vars, err = t.translateNginxVars(part.Match.NginxVars) + if err != nil { + log.Errorw("ApisixRoute with bad nginxVars", + zap.Error(err), + zap.Any("ApisixRoute", ar), + ) + return nil, nil, err + } + } routeName := apisixv1.ComposeRouteName(ar.Namespace, ar.Name, part.Name) upstreamName := apisixv1.ComposeUpstreamName(ar.Namespace, part.Backend.ServiceName, svcPort) @@ -165,6 +176,7 @@ func (t *translator) TranslateRouteV2alpha1(ar *configv2alpha1.ApisixRoute) ([]* ID: id.GenID(routeName), ResourceVersion: ar.ResourceVersion, }, + Vars: vars, Hosts: part.Match.Hosts, Uris: part.Match.Paths, Methods: part.Match.Methods, @@ -201,3 +213,85 @@ func (t *translator) TranslateRouteV2alpha1(ar *configv2alpha1.ApisixRoute) ([]* } return routes, upstreams, nil } + +func (t *translator) translateNginxVars(nginxVars []configv2alpha1.ApisixRouteHTTPMatchNginxVar) ([][]apisixv1.StringOrSlice, error) { + var ( + vars [][]apisixv1.StringOrSlice + op string + ) + for _, expr := range nginxVars { + var ( + invert bool + this []apisixv1.StringOrSlice + ) + if expr.Subject == "" { + return nil, errors.New("empty nginxVar subject") + } + this = append(this, apisixv1.StringOrSlice{ + StrVal: expr.Subject, + }) + + switch expr.Op { + case configv2alpha1.OpEqual: + op = "==" + case configv2alpha1.OpGreaterThan: + op = ">" + // TODO Implement "<=", ">=" operators after the + // lua-resty-expr supports it. See + // https://github.com/api7/lua-resty-expr/issues/28 + // for details. + //case configv2alpha1.OpGreaterThanEqual: + // invert = true + // op = "<" + case configv2alpha1.OpIn: + op = "in" + case configv2alpha1.OpLessThan: + op = "<" + //case configv2alpha1.OpLessThanEqual: + // invert = true + // op = ">" + case configv2alpha1.OpNotEqual: + op = "~=" + case configv2alpha1.OpNotIn: + invert = true + op = "in" + case configv2alpha1.OpRegexMatch: + op = "~~" + case configv2alpha1.OpRegexMatchCaseInsensitive: + op = "~*" + case configv2alpha1.OpRegexNotMatch: + invert = true + op = "~~" + case configv2alpha1.OpRegexNotMatchCaseInsensitive: + invert = true + op = "~*" + default: + return nil, errors.New("unknown operator") + } + if invert { + this = append(this, apisixv1.StringOrSlice{ + StrVal: "!", + }) + } + this = append(this, apisixv1.StringOrSlice{ + StrVal: op, + }) + if expr.Op == configv2alpha1.OpIn || expr.Op == configv2alpha1.OpNotIn { + if expr.Set == nil { + return nil, errors.New("empty set value") + } + this = append(this, apisixv1.StringOrSlice{ + SliceVal: expr.Set, + }) + } else if expr.Value != nil { + this = append(this, apisixv1.StringOrSlice{ + StrVal: *expr.Value, + }) + } else { + return nil, errors.New("neither set nor value is provided") + } + vars = append(vars, this) + } + + return vars, nil +} diff --git a/pkg/kube/translation/apisix_route_test.go b/pkg/kube/translation/apisix_route_test.go new file mode 100644 index 0000000000..0911a6b939 --- /dev/null +++ b/pkg/kube/translation/apisix_route_test.go @@ -0,0 +1,144 @@ +// 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 translation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + configv2alpha1 "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/apis/config/v2alpha1" +) + +func TestNginxVars(t *testing.T) { + tr := &translator{} + value1 := "text/plain" + value2 := "gzip" + value3 := "13" + value4 := ".*\\.php" + ngxVars := []configv2alpha1.ApisixRouteHTTPMatchNginxVar{ + { + Subject: "http_content_type", + Op: configv2alpha1.OpEqual, + Value: &value1, + }, + { + Subject: "http_content_encoding", + Op: configv2alpha1.OpNotEqual, + Value: &value2, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpGreaterThan, + Value: &value3, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpLessThan, + Value: &value3, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpRegexMatch, + Value: &value4, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpRegexMatchCaseInsensitive, + Value: &value4, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpRegexNotMatch, + Value: &value4, + }, + { + Subject: "arg_id", + Op: configv2alpha1.OpRegexNotMatchCaseInsensitive, + Value: &value4, + }, + { + Subject: "remote_addr", + Op: configv2alpha1.OpIn, + Set: []string{ + "10.0.5.3", + "10.0.5.4", + }, + }, + { + Subject: "remote_addr", + Op: configv2alpha1.OpNotIn, + Set: []string{ + "10.0.5.6", + }, + }, + } + vars, err := tr.translateNginxVars(ngxVars) + assert.Nil(t, err) + assert.Len(t, vars, 10) + + assert.Len(t, vars[0], 3) + assert.Equal(t, vars[0][0].StrVal, "http_content_type") + assert.Equal(t, vars[0][1].StrVal, "==") + assert.Equal(t, vars[0][2].StrVal, "text/plain") + + assert.Len(t, vars[1], 3) + assert.Equal(t, vars[1][0].StrVal, "http_content_encoding") + assert.Equal(t, vars[1][1].StrVal, "~=") + assert.Equal(t, vars[1][2].StrVal, "gzip") + + assert.Len(t, vars[2], 3) + assert.Equal(t, vars[2][0].StrVal, "arg_id") + assert.Equal(t, vars[2][1].StrVal, ">") + assert.Equal(t, vars[2][2].StrVal, "13") + + assert.Len(t, vars[3], 3) + assert.Equal(t, vars[3][0].StrVal, "arg_id") + assert.Equal(t, vars[3][1].StrVal, "<") + assert.Equal(t, vars[3][2].StrVal, "13") + + assert.Len(t, vars[4], 3) + assert.Equal(t, vars[4][0].StrVal, "arg_id") + assert.Equal(t, vars[4][1].StrVal, "~~") + assert.Equal(t, vars[4][2].StrVal, ".*\\.php") + + assert.Len(t, vars[5], 3) + assert.Equal(t, vars[5][0].StrVal, "arg_id") + assert.Equal(t, vars[5][1].StrVal, "~*") + assert.Equal(t, vars[5][2].StrVal, ".*\\.php") + + assert.Len(t, vars[6], 4) + assert.Equal(t, vars[6][0].StrVal, "arg_id") + assert.Equal(t, vars[6][1].StrVal, "!") + assert.Equal(t, vars[6][2].StrVal, "~~") + assert.Equal(t, vars[6][3].StrVal, ".*\\.php") + + assert.Len(t, vars[7], 4) + assert.Equal(t, vars[7][0].StrVal, "arg_id") + assert.Equal(t, vars[7][1].StrVal, "!") + assert.Equal(t, vars[7][2].StrVal, "~*") + assert.Equal(t, vars[7][3].StrVal, ".*\\.php") + + assert.Len(t, vars[8], 3) + assert.Equal(t, vars[8][0].StrVal, "remote_addr") + assert.Equal(t, vars[8][1].StrVal, "in") + assert.Equal(t, vars[8][2].SliceVal, []string{"10.0.5.3", "10.0.5.4"}) + + assert.Len(t, vars[9], 4) + assert.Equal(t, vars[9][0].StrVal, "remote_addr") + assert.Equal(t, vars[9][1].StrVal, "!") + assert.Equal(t, vars[9][2].StrVal, "in") + assert.Equal(t, vars[9][3].SliceVal, []string{"10.0.5.6"}) +} diff --git a/pkg/types/apisix/v1/types.go b/pkg/types/apisix/v1/types.go index 57eaf4c3b2..e8d3f4b50d 100644 --- a/pkg/types/apisix/v1/types.go +++ b/pkg/types/apisix/v1/types.go @@ -17,6 +17,7 @@ package v1 import ( "bytes" "encoding/json" + "errors" "strconv" "time" ) @@ -81,17 +82,52 @@ type Metadata struct { type Route struct { Metadata `json:",inline" yaml:",inline"` - Host string `json:"host,omitempty" yaml:"host,omitempty"` - Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` - Path string `json:"path,omitempty" yaml:"path,omitempty"` - Priority int `json:"priority,omitempty" yaml:"priority,omitempty"` - Uris []string `json:"uris,omitempty" yaml:"uris,omitempty"` - Methods []string `json:"methods,omitempty" yaml:"methods,omitempty"` - ServiceId string `json:"service_id,omitempty" yaml:"service_id,omitempty"` - ServiceName string `json:"service_name,omitempty" yaml:"service_name,omitempty"` - UpstreamId string `json:"upstream_id,omitempty" yaml:"upstream_id,omitempty"` - UpstreamName string `json:"upstream_name,omitempty" yaml:"upstream_name,omitempty"` - Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` + Host string `json:"host,omitempty" yaml:"host,omitempty"` + Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Priority int `json:"priority,omitempty" yaml:"priority,omitempty"` + Vars [][]StringOrSlice `json:"vars,omitempty" yaml:"vars,omitempty"` + Uris []string `json:"uris,omitempty" yaml:"uris,omitempty"` + Methods []string `json:"methods,omitempty" yaml:"methods,omitempty"` + ServiceId string `json:"service_id,omitempty" yaml:"service_id,omitempty"` + ServiceName string `json:"service_name,omitempty" yaml:"service_name,omitempty"` + UpstreamId string `json:"upstream_id,omitempty" yaml:"upstream_id,omitempty"` + UpstreamName string `json:"upstream_name,omitempty" yaml:"upstream_name,omitempty"` + Plugins Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"` +} + +// TODO Do not use interface{} to avoid the reflection overheads. +// +k8s:deepcopy-gen=true +type StringOrSlice struct { + StrVal string `json:"-"` + SliceVal []string `json:"-"` +} + +func (s *StringOrSlice) MarshalJSON() ([]byte, error) { + var ( + p []byte + err error + ) + if s.SliceVal != nil { + p, err = json.Marshal(s.SliceVal) + } else { + p, err = json.Marshal(s.StrVal) + } + return p, err +} + +func (s *StringOrSlice) UnmarshalJSON(p []byte) error { + var err error + + if len(p) == 0 { + return errors.New("empty object") + } + if p[0] == '[' { + err = json.Unmarshal(p, &s.SliceVal) + } else { + err = json.Unmarshal(p, &s.StrVal) + } + return err } type Plugins map[string]interface{} diff --git a/pkg/types/apisix/v1/zz_generated.deepcopy.go b/pkg/types/apisix/v1/zz_generated.deepcopy.go index d914f5e19a..ae5470aaeb 100644 --- a/pkg/types/apisix/v1/zz_generated.deepcopy.go +++ b/pkg/types/apisix/v1/zz_generated.deepcopy.go @@ -29,6 +29,19 @@ func (in *Route) DeepCopyInto(out *Route) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Vars != nil { + in, out := &in.Vars, &out.Vars + *out = make([][]StringOrSlice, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = make([]StringOrSlice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + } + } if in.Uris != nil { in, out := &in.Uris, &out.Uris *out = make([]string, len(*in)) @@ -91,6 +104,27 @@ func (in *Ssl) DeepCopy() *Ssl { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StringOrSlice) DeepCopyInto(out *StringOrSlice) { + *out = *in + if in.SliceVal != nil { + in, out := &in.SliceVal, &out.SliceVal + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringOrSlice. +func (in *StringOrSlice) DeepCopy() *StringOrSlice { + if in == nil { + return nil + } + out := new(StringOrSlice) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Upstream) DeepCopyInto(out *Upstream) { *out = *in diff --git a/test/e2e/features/nginx_vars.go b/test/e2e/features/nginx_vars.go new file mode 100644 index 0000000000..1a2bd21cdd --- /dev/null +++ b/test/e2e/features/nginx_vars.go @@ -0,0 +1,576 @@ +// 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 features + +import ( + "fmt" + "net/http" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" + "github.com/onsi/ginkgo" + "github.com/stretchr/testify/assert" +) + +var _ = ginkgo.Describe("nginx vars", func() { + opts := &scaffold.Options{ + Name: "default", + Kubeconfig: scaffold.GetKubeconfig(), + APISIXConfigPath: "testdata/apisix-gw-config.yaml", + APISIXDefaultConfigPath: "testdata/apisix-gw-config-default.yaml", + IngressAPISIXReplicas: 1, + HTTPBinServicePort: 80, + APISIXRouteVersion: "apisix.apache.org/v2alpha1", + } + s := scaffold.NewScaffold(opts) + ginkgo.It("operator is equal", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_foo + op: Equal + value: bar + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Foo", "bar"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Foo", "baz"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is not_equal", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_foo + op: NotEqual + value: bar + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Foo", "bar"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is greater_than", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: arg_id + op: GreaterThan + value: "13" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithQuery("id", 100). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithQuery("id", 3). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + msg = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is less_than", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: arg_id + op: LessThan + value: "13" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithQuery("id", 12). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithQuery("id", 13). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + msg = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is in", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_content_type + op: In + set: ["text/plain", "text/html", "image/jpeg"] + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("Content-Type", "text/html"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("Content-Type", "image/png"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + msg = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is not_in", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_content_type + op: NotIn + set: ["text/plain", "text/html", "image/jpeg"] + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("Content-Type", "text/png"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("Content-Type", "image/jpeg"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK). + Body(). + Raw() + }) + + ginkgo.It("operator is regex match", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_real_uri + op: RegexMatch + value: "^/ip/0\\d{2}/.*$" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/ip/098/v4"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/ip/0983/v4"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + msg = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is regex not match", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_real_uri + op: RegexNotMatch + value: "^/ip/0\\d{2}/.*$" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/ip/0983/v4"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/ip/098/v4"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK). + Body(). + Raw() + }) + + ginkgo.It("operator is regex match in case insensitive mode", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_real_uri + op: RegexMatchCaseInsensitive + value: "^/ip/0\\d{2}/.*$" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/IP/098/v4"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/ip/0983/v4"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + msg = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + }) + + ginkgo.It("operator is regex not match in case insensitive mode", func() { + backendSvc, backendPorts := s.DefaultHTTPBackend() + + ar := fmt.Sprintf(` +apiVersion: apisix.apache.org/v2alpha1 +kind: ApisixRoute +metadata: + name: httpbin-route +spec: + http: + - name: rule1 + match: + hosts: + - httpbin.org + paths: + - /ip + nginxVars: + - subject: http_x_real_uri + op: RegexNotMatchCaseInsensitive + value: "^/ip/0\\d{2}/.*$" + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPorts[0]) + + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ar)) + + err := s.EnsureNumApisixRoutesCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of routes") + err = s.EnsureNumApisixUpstreamsCreated(1) + assert.Nil(ginkgo.GinkgoT(), err, "Checking number of upstreams") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/IP/0983/v4"). + Expect(). + Status(http.StatusOK) + + msg := s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + WithHeader("X-Real-Uri", "/IP/098/v4"). + Expect(). + Status(http.StatusNotFound). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg, "404 Route Not Found") + + _ = s.NewAPISIXClient().GET("/ip"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK). + Body(). + Raw() + }) +})