Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f1f3f0
feat: add Body scope to ApisixRoute match expressions for request bod…
AlinsRan May 9, 2026
a653ef9
feat: add CEL validation and e2e tests for Body scope matching
AlinsRan May 9, 2026
24f7e0c
chore: remove CEL XValidation, keep Enum marker for Scope field
AlinsRan May 9, 2026
930df17
fix: address PR review comments
AlinsRan May 9, 2026
64ffc78
feat: add CEL XValidation for ApisixRouteHTTPMatchExprSubject
AlinsRan May 9, 2026
800c57c
fix: use size(self.name) > 0 in CEL rule to avoid YAML quote issues
AlinsRan May 9, 2026
a32cacf
Apply suggestions from code review
AlinsRan May 11, 2026
92325fd
Potential fix for pull request finding
AlinsRan May 11, 2026
271976c
feat: support Body scope in ApisixRoute HTTP match expressions
AlinsRan May 11, 2026
dd377dd
chore: merge master and fix consumer test after algorithm CEL rule ad…
AlinsRan May 11, 2026
ce311b2
refactor: rename validateObject to Validate, restore missing consumer…
AlinsRan May 11, 2026
a171b69
feat(api): add HealthCheck types to BackendTrafficPolicySpec
AlinsRan May 11, 2026
f839117
chore: regenerate deepcopy for BackendTrafficPolicy health check types
AlinsRan May 11, 2026
0c19a9d
feat: translate BackendTrafficPolicy health checks to APISIX upstream
AlinsRan May 11, 2026
a03d90d
chore: regenerate CRD manifests with BackendTrafficPolicy health chec…
AlinsRan May 11, 2026
9eecf12
fix: use trim() in CEL rule to reject whitespace-only name for non-Pa…
AlinsRan May 12, 2026
3cbb4ca
fix: replace trim() CEL rule with Pattern annotation to avoid cost bu…
AlinsRan May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions api/v2/apisixroute_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,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})

Expand Down Expand Up @@ -410,12 +412,21 @@ type ApisixRouteAuthenticationLDAPAuth struct {
}

// ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression.
// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || self.name != ''",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
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
// 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() {
Expand Down
146 changes: 146 additions & 0 deletions api/v2/apisixroute_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// 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

import (
"os"
"testing"

"github.com/google/cel-go/cel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/yaml"
)
Comment thread
AlinsRan marked this conversation as resolved.
Outdated

func strPtr(s string) *string { return &s }

// celSubjectRule is the CEL expression used in the +kubebuilder:validation:XValidation
// marker on ApisixRouteHTTPMatchExprSubject.
const celSubjectRule = `self.scope == 'Path' || self.name != ''`

func evalCELSubjectRule(t *testing.T, scope, name string) bool {
t.Helper()
env, err := cel.NewEnv(
cel.Variable("self", cel.MapType(cel.StringType, cel.StringType)),
)
require.NoError(t, err)
ast, issues := env.Compile(celSubjectRule)
require.NoError(t, issues.Err())
prg, err := env.Program(ast)
require.NoError(t, err)
out, _, err := prg.Eval(map[string]any{
"self": map[string]any{"scope": scope, "name": name},
})
require.NoError(t, err)
return out.Value().(bool)
}

// TestCEL_SubjectRule_Logic verifies the CEL expression used in the XValidation marker.
func TestCEL_SubjectRule_Logic(t *testing.T) {
// Non-Path scopes with a non-empty name must pass.
for _, scope := range []string{ScopeHeader, ScopeQuery, ScopeCookie, ScopeVariable, ScopeBody} {
assert.True(t, evalCELSubjectRule(t, scope, "field"), "scope=%s with name should pass", scope)
}
// Path scope with empty name must pass (name is ignored for Path).
assert.True(t, evalCELSubjectRule(t, ScopePath, ""), "Path with empty name should pass")
// Non-Path scopes with empty name must fail.
for _, scope := range []string{ScopeHeader, ScopeQuery, ScopeCookie, ScopeVariable, ScopeBody} {
assert.False(t, evalCELSubjectRule(t, scope, ""), "scope=%s with empty name should fail", scope)
}
}

// TestCEL_SubjectRule_InCRD verifies the generated CRD YAML contains the XValidation rule
// with correct (ASCII) quote characters and not typographic quotes.
func TestCEL_SubjectRule_InCRD(t *testing.T) {
const crdPath = "../../config/crd/bases/apisix.apache.org_apisixroutes.yaml"
data, err := os.ReadFile(crdPath)
require.NoError(t, err, "CRD file should exist; run 'make manifests' if missing")

var crd map[string]any
require.NoError(t, yaml.Unmarshal(data, &crd))

raw := string(data)
// The CEL rule must appear with ASCII single-quotes only.
assert.Contains(t, raw, `self.scope == 'Path' || self.name != ''`,
"CRD should contain the XValidation rule with ASCII quotes")
// Ensure no typographic/smart quotes crept in.
assert.NotContains(t, raw, "\u2018", "CRD must not contain left single quotation mark \u2018")
assert.NotContains(t, raw, "\u2019", "CRD must not contain right single quotation mark \u2019")
assert.NotContains(t, raw, "\u201c", "CRD must not contain left double quotation mark \u201c")
assert.NotContains(t, raw, "\u201d", "CRD must not contain right double quotation mark \u201d")
}

func TestToVars_ScopeBody_SimpleField(t *testing.T) {
exprs := ApisixRouteHTTPMatchExprs{
{
Subject: ApisixRouteHTTPMatchExprSubject{
Scope: ScopeBody,
Name: "action",
},
Op: OpEqual,
Value: strPtr("login"),
},
}

vars, err := exprs.ToVars()
require.NoError(t, err)
require.Len(t, vars, 1)

// vars[0] is []StringOrSlice: [subject, op, value]
// Should map to post_arg.action
assert.Equal(t, "post_arg.action", vars[0][0].StrVal)
assert.Equal(t, "==", vars[0][1].StrVal)
assert.Equal(t, "login", vars[0][2].StrVal)
}

func TestToVars_ScopeBody_NestedJSONPath(t *testing.T) {
exprs := ApisixRouteHTTPMatchExprs{
{
Subject: ApisixRouteHTTPMatchExprSubject{
Scope: ScopeBody,
Name: "model.version",
},
Op: OpEqual,
Value: strPtr("gpt-4"),
},
}

vars, err := exprs.ToVars()
require.NoError(t, err)
require.Len(t, vars, 1)

// Should map to post_arg.model.version (dot-notation passthrough)
assert.Equal(t, "post_arg.model.version", vars[0][0].StrVal)
}

func TestToVars_ScopeBody_EmptyName_ReturnsError(t *testing.T) {
exprs := ApisixRouteHTTPMatchExprs{
{
Subject: ApisixRouteHTTPMatchExprSubject{
Scope: ScopeBody,
Name: "",
},
Op: OpEqual,
Value: strPtr("login"),
},
}

_, err := exprs.ToVars()
assert.Error(t, err)
assert.Contains(t, err.Error(), "empty subject.name")
}
5 changes: 5 additions & 0 deletions api/v2/shared_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
23 changes: 19 additions & 4 deletions config/crd/bases/apisix.apache.org_apisixroutes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,33 @@ spec:
It can be any [built-in variable](/apisix/reference/built-in-variables) 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
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
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' || self.name != ''
value:
description: |-
Value defines a single value to compare against the subject.
Expand Down
4 changes: 2 additions & 2 deletions docs/en/latest/reference/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1119,8 +1119,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. |
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
| `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:_
Expand Down
93 changes: 93 additions & 0 deletions test/e2e/crds/v2/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,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() {
if s.Deployer.Name() == framework.ProviderTypeAPI7EE {
Skip("filterFunc is not supported in api7ee")
Expand Down
Loading