This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new c99b26d9 feat: add Body scope to ApisixRoute match expressions for
request body matching (#2762)
c99b26d9 is described below
commit c99b26d9e0eb36dba5cdeea2354033e0aa13b0db
Author: AlinsRan <[email protected]>
AuthorDate: Wed May 13 14:17:48 2026 +0800
feat: add Body scope to ApisixRoute match expressions for request body
matching (#2762)
---
api/v2/apisixconsumer_validation_test.go | 75 +-----------
api/v2/apisixroute_types.go | 19 ++-
api/v2/apisixroute_types_test.go | 127 +++++++++++++++++++++
api/v2/crd_schema_validator_test.go | 98 ++++++++++++++++
api/v2/shared_types.go | 5 +
.../crd/bases/apisix.apache.org_apisixroutes.yaml | 24 +++-
docs/en/latest/reference/api-reference.md | 4 +-
go.mod | 2 +-
go.sum | 14 +++
test/e2e/crds/v2/route.go | 93 +++++++++++++++
10 files changed, 377 insertions(+), 84 deletions(-)
diff --git a/api/v2/apisixconsumer_validation_test.go
b/api/v2/apisixconsumer_validation_test.go
index 5c421315..1f87bb20 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 81b06b9c..0f6ad5a5 100644
--- a/api/v2/apisixroute_types.go
+++ b/api/v2/apisixroute_types.go
@@ -309,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})
@@ -409,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.<name>` 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.<name> 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 900e658a..f692629e 100644
--- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml
+++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml
@@ -206,18 +206,34 @@ spec:
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.<name>` 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.
diff --git a/docs/en/latest/reference/api-reference.md
b/docs/en/latest/reference/api-reference.md
index ce700a73..11357fb3 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -1123,8 +1123,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.<name>` 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:_
diff --git a/go.mod b/go.mod
index e711a732..41e7afae 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
k8s.io/api v0.32.3
k8s.io/apiextensions-apiserver v0.32.3
k8s.io/apimachinery v0.32.3
+ k8s.io/apiserver v0.32.3
k8s.io/client-go v0.32.3
k8s.io/kubectl v0.30.3
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
@@ -209,7 +210,6 @@ require (
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
- k8s.io/apiserver v0.32.3 // indirect
k8s.io/component-base v0.32.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
diff --git a/go.sum b/go.sum
index ffcf6e29..fab518d4 100644
--- a/go.sum
+++ b/go.sum
@@ -117,6 +117,10 @@ github.com/clipperhouse/stringish v0.1.1
h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod
h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.2.0
h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod
h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/coreos/go-semver v0.3.1
h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
+github.com/coreos/go-semver v0.3.1/go.mod
h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
+github.com/coreos/go-systemd/v22 v22.5.0
h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod
h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod
h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod
h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6
h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
@@ -208,6 +212,8 @@ github.com/google/uuid v1.6.0/go.mod
h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod
h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.3
h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod
h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod
h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0
h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod
h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/gruntwork-io/go-commons v0.8.0
h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro=
@@ -434,8 +440,16 @@ github.com/yudai/pp v2.0.1+incompatible/go.mod
h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZ
github.com/yuin/goldmark v1.1.27/go.mod
h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod
h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod
h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.etcd.io/etcd/api/v3 v3.5.16 h1:WvmyJVbjWqK4R1E+B12RRHz3bRGy9XVfh++MgbN+6n0=
+go.etcd.io/etcd/api/v3 v3.5.16/go.mod
h1:1P4SlIP/VwkDmGo3OlOD7faPeP8KDIFhqvciH5EfN28=
+go.etcd.io/etcd/client/pkg/v3 v3.5.16
h1:ZgY48uH6UvB+/7R9Yf4x574uCO3jIx0TRDyetSfId3Q=
+go.etcd.io/etcd/client/pkg/v3 v3.5.16/go.mod
h1:V8acl8pcEK0Y2g19YlOV9m9ssUe6MgiDSobSoaBAM0E=
+go.etcd.io/etcd/client/v3 v3.5.16
h1:sSmVYOAHeC9doqi0gv7v86oY/BTld0SEFGaxsU9eRhE=
+go.etcd.io/etcd/client/v3 v3.5.16/go.mod
h1:X+rExSGkyqxvu276cr2OwPLBaeqFu1cIl4vmRjAD/50=
go.opentelemetry.io/auto/sdk v1.2.1
h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod
h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod
h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.40.0
h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go
index 7f693cf8..c1a5cffe 100644
--- a/test/e2e/crds/v2/route.go
+++ b/test/e2e/crds/v2/route.go
@@ -289,6 +289,99 @@ spec:
s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound)
})
+ It("Test ApisixRoute match by body vars (urlencoded)", func() {
+ const apisixRouteSpec = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+ name: default
+ namespace: %s
+spec:
+ ingressClassName: %s
+ http:
+ - name: rule0
+ match:
+ paths:
+ - /*
+ methods:
+ - POST
+ exprs:
+ - subject:
+ scope: Body
+ name: action
+ op: Equal
+ value: login
+ backends:
+ - serviceName: httpbin-service-e2e-test
+ servicePort: 80
+`
+ By("apply ApisixRoute with Body scope expr")
+ var apisixRoute apiv2.ApisixRoute
+ applier.MustApplyAPIv2(types.NamespacedName{Namespace:
s.Namespace(), Name: "default"},
+ &apisixRoute, fmt.Sprintf(apisixRouteSpec,
s.Namespace(), s.Namespace()))
+
+ By("verify matching POST with form field action=login
returns 200")
+ request := func() int {
+ return s.NewAPISIXClient().POST("/post").
+ WithFormField("action", "login").
+ Expect().Raw().StatusCode
+ }
+ Eventually(request).WithTimeout(20 *
time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK))
+
+ By("verify non-matching POST with wrong action value
returns 404")
+ s.NewAPISIXClient().POST("/post").
+ WithFormField("action", "logout").
+ Expect().Status(http.StatusNotFound)
+
+ By("verify GET request (no body) returns 404")
+
s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound)
+ })
+
+ It("Test ApisixRoute match by body vars (JSON nested path)",
func() {
+ const apisixRouteSpec = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+ name: default
+ namespace: %s
+spec:
+ ingressClassName: %s
+ http:
+ - name: rule0
+ match:
+ paths:
+ - /*
+ methods:
+ - POST
+ exprs:
+ - subject:
+ scope: Body
+ name: model.version
+ op: Equal
+ value: gpt-4
+ backends:
+ - serviceName: httpbin-service-e2e-test
+ servicePort: 80
+`
+ By("apply ApisixRoute with Body scope dot-notation JSON
path expr")
+ var apisixRoute apiv2.ApisixRoute
+ applier.MustApplyAPIv2(types.NamespacedName{Namespace:
s.Namespace(), Name: "default"},
+ &apisixRoute, fmt.Sprintf(apisixRouteSpec,
s.Namespace(), s.Namespace()))
+
+ By("verify matching POST with JSON body {model:
{version: gpt-4}} returns 200")
+ request := func() int {
+ return s.NewAPISIXClient().POST("/post").
+ WithJSON(map[string]any{"model":
map[string]string{"version": "gpt-4"}}).
+ Expect().Raw().StatusCode
+ }
+ Eventually(request).WithTimeout(20 *
time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK))
+
+ By("verify non-matching JSON body with wrong nested
value returns 404")
+ s.NewAPISIXClient().POST("/post").
+ WithJSON(map[string]any{"model":
map[string]string{"version": "gpt-3"}}).
+ Expect().Status(http.StatusNotFound)
+ })
+
It("Test ApisixRoute filterFunc", func() {
const apisixRouteSpec = `
apiVersion: apisix.apache.org/v2