Skip to content

Commit a653ef9

Browse files
committed
feat: add CEL validation and e2e tests for Body scope matching
- Add +kubebuilder:validation:Enum marker to Scope field (Header/Query/Path/Cookie/Variable/Body) - Add +kubebuilder:validation:XValidation CEL rule enforcing Name is required when Scope is not Path: 'self.scope == "Path" || self.name != ""' - Regenerate CRD YAML with the new enum and x-kubernetes-validations rules - Add unit tests verifying the CEL expression correctness via cel-go evaluation - Add e2e tests for Body scope: urlencoded form field matching and JSON body matching
1 parent 3f1f3f0 commit a653ef9

4 files changed

Lines changed: 157 additions & 4 deletions

File tree

api/v2/apisixroute_types.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,15 +412,18 @@ type ApisixRouteAuthenticationLDAPAuth struct {
412412
}
413413

414414
// ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression.
415+
// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || self.name != ”",message="name is required when scope is not Path"
415416
type ApisixRouteHTTPMatchExprSubject struct {
416417
// Scope specifies the subject scope.
417418
// Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`.
418419
// When Scope is `Path`, Name will be ignored.
419420
// When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version",
420421
// "messages[*].role") and maps to APISIX's post_arg.* variable, which works with
421422
// application/json, application/x-www-form-urlencoded, and multipart/form-data.
423+
// +kubebuilder:validation:Enum=Header;Query;Path;Cookie;Variable;Body
422424
Scope string `json:"scope" yaml:"scope"`
423-
// Name is the name of the header or query parameter.
425+
// Name is the name of the header, query parameter, cookie, variable, or body field.
426+
// Required for all scopes except Path.
424427
Name string `json:"name" yaml:"name"`
425428
}
426429

api/v2/apisixroute_types_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,52 @@ package v2
2020
import (
2121
"testing"
2222

23+
"github.com/google/cel-go/cel"
2324
"github.com/stretchr/testify/assert"
2425
"github.com/stretchr/testify/require"
2526
)
2627

28+
// celSubjectRule is the CEL expression embedded via +kubebuilder:validation:XValidation
29+
// on ApisixRouteHTTPMatchExprSubject. This test validates its correctness.
30+
const celSubjectRule = "self.scope == 'Path' || self.name != ''"
31+
32+
// evalCELSubjectRule evaluates celSubjectRule against a fake subject object.
33+
func evalCELSubjectRule(t *testing.T, scope, name string) bool {
34+
t.Helper()
35+
env, err := cel.NewEnv(
36+
cel.Variable("self", cel.MapType(cel.StringType, cel.StringType)),
37+
)
38+
require.NoError(t, err)
39+
40+
ast, issues := env.Compile(celSubjectRule)
41+
require.NoError(t, issues.Err())
42+
43+
prg, err := env.Program(ast)
44+
require.NoError(t, err)
45+
46+
out, _, err := prg.Eval(map[string]any{
47+
"self": map[string]any{"scope": scope, "name": name},
48+
})
49+
require.NoError(t, err)
50+
return out.Value().(bool)
51+
}
52+
53+
func TestCEL_SubjectRule_ValidScopes(t *testing.T) {
54+
// All non-Path scopes with a non-empty name must pass.
55+
for _, scope := range []string{ScopeHeader, ScopeQuery, ScopeCookie, ScopeVariable, ScopeBody} {
56+
assert.True(t, evalCELSubjectRule(t, scope, "field"), "scope=%s with name should pass", scope)
57+
}
58+
// Path scope with empty name must pass (name is ignored for Path).
59+
assert.True(t, evalCELSubjectRule(t, ScopePath, ""), "Path with empty name should pass")
60+
}
61+
62+
func TestCEL_SubjectRule_InvalidEmptyName(t *testing.T) {
63+
// Non-Path scopes with empty name must fail.
64+
for _, scope := range []string{ScopeHeader, ScopeQuery, ScopeCookie, ScopeVariable, ScopeBody} {
65+
assert.False(t, evalCELSubjectRule(t, scope, ""), "scope=%s with empty name should fail", scope)
66+
}
67+
}
68+
2769
func strPtr(s string) *string { return &s }
2870

2971
func TestToVars_ScopeBody_SimpleField(t *testing.T) {

config/crd/bases/apisix.apache.org_apisixroutes.yaml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,18 +206,33 @@ spec:
206206
It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal.
207207
properties:
208208
name:
209-
description: Name is the name of the header or
210-
query parameter.
209+
description: |-
210+
Name is the name of the header, query parameter, cookie, variable, or body field.
211+
Required for all scopes except Path.
211212
type: string
212213
scope:
213214
description: |-
214-
Scope specifies the subject scope and can be `Header`, `Query`, or `Path`.
215+
Scope specifies the subject scope.
216+
Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`.
215217
When Scope is `Path`, Name will be ignored.
218+
When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version",
219+
"messages[*].role") and maps to APISIX's post_arg.* variable, which works with
220+
application/json, application/x-www-form-urlencoded, and multipart/form-data.
221+
enum:
222+
- Header
223+
- Query
224+
- Path
225+
- Cookie
226+
- Variable
227+
- Body
216228
type: string
217229
required:
218230
- name
219231
- scope
220232
type: object
233+
x-kubernetes-validations:
234+
- message: name is required when scope is not Path
235+
rule: self.scope == 'Path' || self.name != ”
221236
value:
222237
description: |-
223238
Value defines a single value to compare against the subject.

test/e2e/crds/v2/route.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,99 @@ spec:
291291
s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound)
292292
})
293293

294+
It("Test ApisixRoute match by body vars (urlencoded)", func() {
295+
const apisixRouteSpec = `
296+
apiVersion: apisix.apache.org/v2
297+
kind: ApisixRoute
298+
metadata:
299+
name: default
300+
namespace: %s
301+
spec:
302+
ingressClassName: %s
303+
http:
304+
- name: rule0
305+
match:
306+
paths:
307+
- /*
308+
methods:
309+
- POST
310+
exprs:
311+
- subject:
312+
scope: Body
313+
name: action
314+
op: Equal
315+
value: login
316+
backends:
317+
- serviceName: httpbin-service-e2e-test
318+
servicePort: 80
319+
`
320+
By("apply ApisixRoute with Body scope expr")
321+
var apisixRoute apiv2.ApisixRoute
322+
applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"},
323+
&apisixRoute, fmt.Sprintf(apisixRouteSpec, s.Namespace(), s.Namespace()))
324+
325+
By("verify matching POST with form field action=login returns 200")
326+
request := func() int {
327+
return s.NewAPISIXClient().POST("/post").
328+
WithFormField("action", "login").
329+
Expect().Raw().StatusCode
330+
}
331+
Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK))
332+
333+
By("verify non-matching POST without action field returns 404")
334+
s.NewAPISIXClient().POST("/post").
335+
WithFormField("action", "logout").
336+
Expect().Status(http.StatusNotFound)
337+
338+
By("verify GET request (no body) returns 404")
339+
s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound)
340+
})
341+
342+
It("Test ApisixRoute match by body vars (JSON path)", func() {
343+
const apisixRouteSpec = `
344+
apiVersion: apisix.apache.org/v2
345+
kind: ApisixRoute
346+
metadata:
347+
name: default
348+
namespace: %s
349+
spec:
350+
ingressClassName: %s
351+
http:
352+
- name: rule0
353+
match:
354+
paths:
355+
- /*
356+
methods:
357+
- POST
358+
exprs:
359+
- subject:
360+
scope: Body
361+
name: model
362+
op: Equal
363+
value: gpt-4
364+
backends:
365+
- serviceName: httpbin-service-e2e-test
366+
servicePort: 80
367+
`
368+
By("apply ApisixRoute with Body scope JSON path expr")
369+
var apisixRoute apiv2.ApisixRoute
370+
applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"},
371+
&apisixRoute, fmt.Sprintf(apisixRouteSpec, s.Namespace(), s.Namespace()))
372+
373+
By("verify matching POST with JSON body model=gpt-4 returns 200")
374+
request := func() int {
375+
return s.NewAPISIXClient().POST("/post").
376+
WithJSON(map[string]string{"model": "gpt-4"}).
377+
Expect().Raw().StatusCode
378+
}
379+
Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK))
380+
381+
By("verify non-matching JSON body returns 404")
382+
s.NewAPISIXClient().POST("/post").
383+
WithJSON(map[string]string{"model": "gpt-3"}).
384+
Expect().Status(http.StatusNotFound)
385+
})
386+
294387
It("Test ApisixRoute filterFunc", func() {
295388
if s.Deployer.Name() == framework.ProviderTypeAPI7EE {
296389
Skip("filterFunc is not supported in api7ee")

0 commit comments

Comments
 (0)