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

Reply via email to