From 334db60f039b2b8c408598fdfdff114a9fabea2f Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 14:26:00 +0800 Subject: [PATCH] feat: support Body scope in ApisixRoute HTTP match expressions Adds support for matching on request body fields in ApisixRoute, mapping to APISIX's post_arg. variable. Supports application/json (with dot-notation JSON path), application/x-www-form-urlencoded, and multipart/form-data content types. - Add ScopeBody constant to shared_types.go - Add ToVars() case for ScopeBody mapping to post_arg. - Add Body to Scope enum validation marker - Add CEL XValidation: name required when scope is not Path - Make Name field optional (omitempty) since Path scope needs no name - Add shared CRD schema validator for unit tests (crd_schema_validator_test.go) - Migrate ApisixConsumer validation tests to shared validator - Add ApisixRoute body scope unit tests - Regenerate CRD manifest and API reference docs Synced from apache/apisix-ingress-controller#2762 --- api/v2/apisixconsumer_validation_test.go | 75 +---------- api/v2/apisixroute_types.go | 22 ++- api/v2/apisixroute_types_test.go | 127 ++++++++++++++++++ api/v2/crd_schema_validator_test.go | 98 ++++++++++++++ api/v2/shared_types.go | 5 + .../bases/apisix.apache.org_apisixroutes.yaml | 27 +++- docs/en/latest/reference/api-reference.md | 10 +- 7 files changed, 274 insertions(+), 90 deletions(-) create mode 100644 api/v2/apisixroute_types_test.go create mode 100644 api/v2/crd_schema_validator_test.go diff --git a/api/v2/apisixconsumer_validation_test.go b/api/v2/apisixconsumer_validation_test.go index 88fdd1d6..821b733f 100644 --- a/api/v2/apisixconsumer_validation_test.go +++ b/api/v2/apisixconsumer_validation_test.go @@ -16,93 +16,22 @@ package v2_test import ( - "context" - "encoding/json" - "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" - "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" - "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" - celconfig "k8s.io/apiserver/pkg/apis/cel" - sigsyaml "sigs.k8s.io/yaml" apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" ) -// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer -// and provides a Validate method for use in tests. -type consumerSchemaValidator struct { - structural *structuralschema.Structural - internal *apiextensions.JSONSchemaProps -} - -func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error { - t.Helper() - - data, err := json.Marshal(ac) - require.NoError(t, err, "failed to marshal ApisixConsumer") - - var obj map[string]interface{} - require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map") - - schemaValidator, _, err := validation.NewSchemaValidator(v.internal) - require.NoError(t, err, "failed to build schema validator") - - if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 { - return errs.ToAggregate() - } - - celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit) - celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget) - if len(celErrs) > 0 { - return celErrs.ToAggregate() - } - return nil -} - -// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a -// validator backed by the real generated schema. -func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator { +func loadApisixConsumerSchema(t *testing.T) *crdSchemaValidator { t.Helper() - _, thisFile, _, _ := runtime.Caller(0) crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml") - - data, err := os.ReadFile(crdPath) - require.NoError(t, err, "failed to read CRD file: %s", crdPath) - - jsonData, err := sigsyaml.YAMLToJSON(data) - require.NoError(t, err, "failed to convert CRD YAML to JSON") - - var crd apiextensionsv1.CustomResourceDefinition - require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD") - - var v1Schema *apiextensionsv1.JSONSchemaProps - for _, v := range crd.Spec.Versions { - if v.Name == "v2" { - v1Schema = v.Schema.OpenAPIV3Schema - break - } - } - require.NotNil(t, v1Schema, "v2 schema not found in CRD") - - var internal apiextensions.JSONSchemaProps - require.NoError(t, - apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil), - "failed to convert v1 schema to internal", - ) - - structural, err := structuralschema.NewStructural(&internal) - require.NoError(t, err, "failed to build structural schema") - return &consumerSchemaValidator{structural: structural, internal: &internal} + return loadCRDSchema(t, crdPath) } func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) { diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index dd30d021..0f6ad5a5 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -164,7 +164,6 @@ type ApisixRouteHTTPMatch struct { // FilterFunc is a user-defined function for advanced request filtering. // The function can use Nginx variables through the `vars` parameter. - // This field is supported in APISIX but not in API7 Enterprise. FilterFunc string `json:"filter_func,omitempty" yaml:"filter_func,omitempty"` } @@ -266,7 +265,7 @@ type ApisixRouteStreamBackend struct { // ApisixRouteHTTPMatchExpr represents a binary expression used to match requests based on Nginx variables. type ApisixRouteHTTPMatchExpr struct { // Subject defines the left-hand side of the expression. - // It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. + // It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. Subject ApisixRouteHTTPMatchExprSubject `json:"subject" yaml:"subject"` // Op specifies the operator used in the expression. @@ -310,8 +309,10 @@ func (exprs ApisixRouteHTTPMatchExprs) ToVars() (result adc.Vars, err error) { subj = "uri" case ScopeVariable: subj = expr.Subject.Name + case ScopeBody: + subj = "post_arg." + expr.Subject.Name default: - return result, errors.New("invalid http match expr: subject.scope should be one of [query, header, cookie, path, variable]") + return result, errors.New("invalid http match expr: subject.scope should be one of [Query, Header, Cookie, Path, Variable, Body]") } this.SliceVal = append(this.SliceVal, adc.StringOrSlice{StrVal: subj}) @@ -410,12 +411,21 @@ type ApisixRouteAuthenticationLDAPAuth struct { } // ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression. +// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || size(self.name) > 0",message="name is required when scope is not Path" type ApisixRouteHTTPMatchExprSubject struct { - // Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. + // Scope specifies the subject scope. + // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. // When Scope is `Path`, Name will be ignored. + // When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", + // "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with + // application/json, application/x-www-form-urlencoded, and multipart/form-data. + // +kubebuilder:validation:Enum=Header;Query;Path;Cookie;Variable;Body Scope string `json:"scope" yaml:"scope"` - // Name is the name of the header or query parameter. - Name string `json:"name" yaml:"name"` + // Name is the name of the subject within the given scope: the header name, query + // parameter name, cookie name, Nginx variable name, or body field name (dot-notation + // JSON path supported for Body scope). Optional when Scope is Path. + // +kubebuilder:validation:Optional + Name string `json:"name,omitempty" yaml:"name,omitempty"` } func init() { diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go new file mode 100644 index 00000000..d5093477 --- /dev/null +++ b/api/v2/apisixroute_types_test.go @@ -0,0 +1,127 @@ +// 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 v2_test + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/intstr" + + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" +) + +func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator { + t.Helper() + _, thisFile, _, _ := runtime.Caller(0) + crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..", + "config", "crd", "bases", "apisix.apache.org_apisixroutes.yaml") + return loadCRDSchema(t, crdPath) +} + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func intPtr(i int) *int { return &i } + +func newRouteWithBodyExpr(ingressClass, fieldName, value string) *apisixv2.ApisixRoute { + return &apisixv2.ApisixRoute{ + Spec: apisixv2.ApisixRouteSpec{ + IngressClassName: ingressClass, + HTTP: []apisixv2.ApisixRouteHTTP{ + { + Name: "rule0", + Websocket: boolPtr(false), + Match: apisixv2.ApisixRouteHTTPMatch{ + Paths: []string{"/*"}, + NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{ + { + Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{ + Scope: apisixv2.ScopeBody, + Name: fieldName, + }, + Op: apisixv2.OpEqual, + Set: []string{}, + Value: strPtr(value), + }, + }, + }, + Backends: []apisixv2.ApisixRouteHTTPBackend{ + {ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)}, + }, + }, + }, + }, + } +} + +// TestApisixRoute_BodyScope_SimpleField verifies that a Body scope expr with a +// simple field name passes CRD schema validation. +func TestApisixRoute_BodyScope_SimpleField(t *testing.T) { + v := loadApisixRouteSchema(t) + assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "action", "login"))) +} + +// TestApisixRoute_BodyScope_NestedJSONPath verifies that a Body scope expr with +// a dot-notation JSON path passes CRD schema validation. +func TestApisixRoute_BodyScope_NestedJSONPath(t *testing.T) { + v := loadApisixRouteSchema(t) + assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4"))) +} + +// TestApisixRoute_BodyScope_EmptyName verifies that a Body scope expr with an +// empty name is rejected by the CEL XValidation rule. +func TestApisixRoute_BodyScope_EmptyName(t *testing.T) { + v := loadApisixRouteSchema(t) + err := v.Validate(t, newRouteWithBodyExpr("apisix", "", "login")) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required when scope is not Path") +} + +// TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name +// passes CRD schema validation (name is optional for Path). +func TestApisixRoute_PathScope_EmptyName(t *testing.T) { + v := loadApisixRouteSchema(t) + ar := &apisixv2.ApisixRoute{ + Spec: apisixv2.ApisixRouteSpec{ + HTTP: []apisixv2.ApisixRouteHTTP{ + { + Name: "rule0", + Websocket: boolPtr(false), + Match: apisixv2.ApisixRouteHTTPMatch{ + Paths: []string{"/*"}, + NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{ + { + Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{ + Scope: apisixv2.ScopePath, + }, + Op: apisixv2.OpEqual, + Set: []string{}, + Value: strPtr("/api"), + }, + }, + }, + Backends: []apisixv2.ApisixRouteHTTPBackend{ + {ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)}, + }, + }, + }, + }, + } + assert.NoError(t, v.Validate(t, ar)) +} diff --git a/api/v2/crd_schema_validator_test.go b/api/v2/crd_schema_validator_test.go new file mode 100644 index 00000000..0337b856 --- /dev/null +++ b/api/v2/crd_schema_validator_test.go @@ -0,0 +1,98 @@ +// 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 v2_test + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + celconfig "k8s.io/apiserver/pkg/apis/cel" + sigsyaml "sigs.k8s.io/yaml" +) + +// crdSchemaValidator holds the parsed CRD schema and validates objects against it, +// including both OpenAPI structural validation and CEL x-kubernetes-validations rules. +type crdSchemaValidator struct { + structural *structuralschema.Structural + internal *apiextensions.JSONSchemaProps +} + +// Validate marshals obj to JSON then runs the CRD's OpenAPI schema validator +// followed by any CEL x-kubernetes-validations rules. +func (v *crdSchemaValidator) Validate(t *testing.T, obj any) error { + t.Helper() + + data, err := json.Marshal(obj) + require.NoError(t, err, "failed to marshal object") + + var raw map[string]interface{} + require.NoError(t, json.Unmarshal(data, &raw), "failed to unmarshal to map") + + schemaValidator, _, err := validation.NewSchemaValidator(v.internal) + require.NoError(t, err, "failed to build schema validator") + + if errs := validation.ValidateCustomResource(nil, raw, schemaValidator); len(errs) > 0 { + return errs.ToAggregate() + } + + celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit) + celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, raw, nil, celconfig.RuntimeCELCostBudget) + if len(celErrs) > 0 { + return celErrs.ToAggregate() + } + return nil +} + +// loadCRDSchema reads a CRD YAML file and returns a validator for the "v2" version schema. +func loadCRDSchema(t *testing.T, crdPath string) *crdSchemaValidator { + t.Helper() + + data, err := os.ReadFile(crdPath) + require.NoError(t, err, "failed to read CRD file: %s", crdPath) + + jsonData, err := sigsyaml.YAMLToJSON(data) + require.NoError(t, err, "failed to convert CRD YAML to JSON") + + var crd apiextensionsv1.CustomResourceDefinition + require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD") + + var v1Schema *apiextensionsv1.JSONSchemaProps + for _, v := range crd.Spec.Versions { + if v.Name == "v2" { + v1Schema = v.Schema.OpenAPIV3Schema + break + } + } + require.NotNil(t, v1Schema, "v2 schema not found in CRD") + + var internal apiextensions.JSONSchemaProps + require.NoError(t, + apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil), + "failed to convert v1 schema to internal", + ) + + structural, err := structuralschema.NewStructural(&internal) + require.NoError(t, err, "failed to build structural schema") + return &crdSchemaValidator{structural: structural, internal: &internal} +} diff --git a/api/v2/shared_types.go b/api/v2/shared_types.go index 6c2c2934..d5fd043f 100644 --- a/api/v2/shared_types.go +++ b/api/v2/shared_types.go @@ -86,6 +86,11 @@ const ( ScopeCookie = "Cookie" // ScopeVariable means the route match expression subject is in variable. ScopeVariable = "Variable" + // ScopeBody means the route match expression subject is in the request body. + // Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role"), + // and maps to APISIX's post_arg. variable, which supports application/json, + // application/x-www-form-urlencoded, and multipart/form-data content types. + ScopeBody = "Body" ) const ( diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index ff2c6dd3..f692629e 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -203,21 +203,37 @@ spec: subject: description: |- Subject defines the left-hand side of the expression. - It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. + It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. properties: name: - description: Name is the name of the header or - query parameter. + description: |- + Name is the name of the subject within the given scope: the header name, query + parameter name, cookie name, Nginx variable name, or body field name (dot-notation + JSON path supported for Body scope). Optional when Scope is Path. type: string scope: description: |- - Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. + Scope specifies the subject scope. + Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. When Scope is `Path`, Name will be ignored. + When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", + "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with + application/json, application/x-www-form-urlencoded, and multipart/form-data. + enum: + - Header + - Query + - Path + - Cookie + - Variable + - Body type: string required: - - name - scope type: object + x-kubernetes-validations: + - message: name is required when scope is not Path + rule: self.scope == 'Path' || size(self.name) > + 0 value: description: |- Value defines a single value to compare against the subject. @@ -233,7 +249,6 @@ spec: description: |- FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. - This field is supported in APISIX but not in API7 Enterprise. type: string hosts: description: |- diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index 9482e1d3..3be2f998 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -1089,7 +1089,7 @@ ApisixRouteHTTPMatch defines the conditions used to match incoming HTTP requests | `hosts` _string array_ | Hosts specifies Host header values to match. Supports exact and wildcard domains. Only one level of wildcard is allowed (e.g., `*.example.com` is valid, but `*.*.example.com` is not). | | `remoteAddrs` _string array_ | RemoteAddrs is a list of source IP addresses or CIDR ranges to match. Supports both IPv4 and IPv6 formats. | | `exprs` _[ApisixRouteHTTPMatchExprs](#apisixroutehttpmatchexprs)_ | NginxVars defines match conditions based on Nginx variables. | -| `filter_func` _string_ | FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. This field is supported in APISIX but not in API7 Enterprise. | +| `filter_func` _string_ | FilterFunc is a user-defined function for advanced request filtering. The function can use Nginx variables through the `vars` parameter. | _Appears in:_ @@ -1104,7 +1104,7 @@ ApisixRouteHTTPMatchExpr represents a binary expression used to match requests b | Field | Description | | --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. | | `op` _string_ | Op specifies the operator used in the expression. Can be `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `RegexMatch`, `RegexNotMatch`, `RegexMatchCaseInsensitive`, `RegexNotMatchCaseInsensitive`, `In`, or `NotIn`. | | `set` _string array_ | Set provides a list of acceptable values for the expression. This should be used when Op is `In` or `NotIn`. | | `value` _string_ | Value defines a single value to compare against the subject. This should be used when Op is not `In` or `NotIn`. Set and Value are mutually exclusive—only one should be set at a time. | @@ -1122,8 +1122,8 @@ ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expres | Field | Description | | --- | --- | -| `scope` _string_ | Scope specifies the subject scope and can be `Header`, `Query`, or `Path`. When Scope is `Path`, Name will be ignored. | -| `name` _string_ | Name is the name of the header or query parameter. | +| `scope` _string_ | Scope specifies the subject scope. Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. When Scope is `Path`, Name will be ignored. When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role") and maps to APISIX's `post_arg.` variable, which works with application/json, application/x-www-form-urlencoded, and multipart/form-data. | +| `name` _string_ | Name is the name of the subject within the given scope: the header name, query parameter name, cookie name, Nginx variable name, or body field name (dot-notation JSON path supported for Body scope). Optional when Scope is Path. | _Appears in:_ @@ -1138,7 +1138,7 @@ _Base type:_ `[ApisixRouteHTTPMatchExpr](#apisixroutehttpmatchexpr)` | Field | Description | | --- | --- | -| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal. | +| `subject` _[ApisixRouteHTTPMatchExprSubject](#apisixroutehttpmatchexprsubject)_ | Subject defines the left-hand side of the expression. It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal. | | `op` _string_ | Op specifies the operator used in the expression. Can be `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `RegexMatch`, `RegexNotMatch`, `RegexMatchCaseInsensitive`, `RegexNotMatchCaseInsensitive`, `In`, or `NotIn`. | | `set` _string array_ | Set provides a list of acceptable values for the expression. This should be used when Op is `In` or `NotIn`. | | `value` _string_ | Value defines a single value to compare against the subject. This should be used when Op is not `In` or `NotIn`. Set and Value are mutually exclusive—only one should be set at a time. |