From 3f1f3f0eb40c8a763ae49bd549d231e37a8b1624 Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 13:54:07 +0800 Subject: [PATCH 01/16] feat: add Body scope to ApisixRoute match expressions for request body matching Add ScopeBody to ApisixRouteHTTPMatchExprSubject.Scope, which maps to APISIX's post_arg.* variable. This supports request body matching for application/json, application/x-www-form-urlencoded, and multipart/form-data content types, and allows dot-notation JSON path expressions such as 'model.version' and 'messages[*].role'. Closes #399 --- api/v2/apisixroute_types.go | 10 +++- api/v2/apisixroute_types_test.go | 87 ++++++++++++++++++++++++++++++++ api/v2/shared_types.go | 5 ++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 api/v2/apisixroute_types_test.go diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index dd30d021..b3d5d95c 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -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}) @@ -411,8 +413,12 @@ type ApisixRouteAuthenticationLDAPAuth struct { // ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression. 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. Scope string `json:"scope" yaml:"scope"` // Name is the name of the header or query parameter. Name string `json:"name" yaml:"name"` diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go new file mode 100644 index 00000000..dc3dc269 --- /dev/null +++ b/api/v2/apisixroute_types_test.go @@ -0,0 +1,87 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +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") +} diff --git a/api/v2/shared_types.go b/api/v2/shared_types.go index 6c2c2934..c0a09776 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 ( From a653ef9a6e2779bc77be91fc2d3d6a34f297cbad Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 14:03:28 +0800 Subject: [PATCH 02/16] 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 --- api/v2/apisixroute_types.go | 5 +- api/v2/apisixroute_types_test.go | 42 +++++++++ .../bases/apisix.apache.org_apisixroutes.yaml | 21 ++++- test/e2e/crds/v2/route.go | 93 +++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index b3d5d95c..3d9067b9 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -412,6 +412,7 @@ 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. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. @@ -419,8 +420,10 @@ type ApisixRouteHTTPMatchExprSubject struct { // 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 is the name of the header, query parameter, cookie, variable, or body field. + // Required for all scopes except Path. Name string `json:"name" yaml:"name"` } diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index dc3dc269..a8bfa62a 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -20,10 +20,52 @@ package v2 import ( "testing" + "github.com/google/cel-go/cel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// celSubjectRule is the CEL expression embedded via +kubebuilder:validation:XValidation +// on ApisixRouteHTTPMatchExprSubject. This test validates its correctness. +const celSubjectRule = "self.scope == 'Path' || self.name != ''" + +// evalCELSubjectRule evaluates celSubjectRule against a fake subject object. +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) +} + +func TestCEL_SubjectRule_ValidScopes(t *testing.T) { + // All 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") +} + +func TestCEL_SubjectRule_InvalidEmptyName(t *testing.T) { + // 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) + } +} + func strPtr(s string) *string { return &s } func TestToVars_ScopeBody_SimpleField(t *testing.T) { diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index ff2c6dd3..d6b62b1d 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -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 header, query parameter, cookie, variable, or body field. + Required for all scopes except 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' || self.name != ” value: description: |- Value defines a single value to compare against the subject. diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go index d228885d..c4bc5be4 100644 --- a/test/e2e/crds/v2/route.go +++ b/test/e2e/crds/v2/route.go @@ -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 without action field 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 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 + op: Equal + value: gpt-4 + backends: + - serviceName: httpbin-service-e2e-test + servicePort: 80 +` + By("apply ApisixRoute with Body scope 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=gpt-4 returns 200") + request := func() int { + return s.NewAPISIXClient().POST("/post"). + WithJSON(map[string]string{"model": "gpt-4"}). + Expect().Raw().StatusCode + } + Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) + + By("verify non-matching JSON body returns 404") + s.NewAPISIXClient().POST("/post"). + WithJSON(map[string]string{"model": "gpt-3"}). + Expect().Status(http.StatusNotFound) + }) + It("Test ApisixRoute filterFunc", func() { if s.Deployer.Name() == framework.ProviderTypeAPI7EE { Skip("filterFunc is not supported in api7ee") From 24f7e0ced601f82e8e757aa4cf5bded35af81bdb Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 14:17:42 +0800 Subject: [PATCH 03/16] chore: remove CEL XValidation, keep Enum marker for Scope field --- api/v2/apisixroute_types.go | 1 - api/v2/apisixroute_types_test.go | 42 ------------------- .../bases/apisix.apache.org_apisixroutes.yaml | 3 -- 3 files changed, 46 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index 3d9067b9..00387853 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -412,7 +412,6 @@ 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. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index a8bfa62a..dc3dc269 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -20,52 +20,10 @@ package v2 import ( "testing" - "github.com/google/cel-go/cel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// celSubjectRule is the CEL expression embedded via +kubebuilder:validation:XValidation -// on ApisixRouteHTTPMatchExprSubject. This test validates its correctness. -const celSubjectRule = "self.scope == 'Path' || self.name != ''" - -// evalCELSubjectRule evaluates celSubjectRule against a fake subject object. -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) -} - -func TestCEL_SubjectRule_ValidScopes(t *testing.T) { - // All 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") -} - -func TestCEL_SubjectRule_InvalidEmptyName(t *testing.T) { - // 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) - } -} - func strPtr(s string) *string { return &s } func TestToVars_ScopeBody_SimpleField(t *testing.T) { diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index d6b62b1d..272bbd22 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -230,9 +230,6 @@ spec: - 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. From 930df1768e0c4b65cf8a34d983f8d76535ea0331 Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 14:41:31 +0800 Subject: [PATCH 04/16] fix: address PR review comments - Fix error message scope values to use canonical casing (Query/Header/etc.) - Fix ScopeBody doc comment: post_arg.* -> post_arg. - Update Name field comment to be generic across all scopes - Make Name field optional (omitempty) so scope:Path does not require name - Fix e2e step description: 'without action field' -> 'wrong action value' - Fix e2e JSON path test to use actual dot-notation (model.version) --- api/v2/apisixroute_types.go | 12 +++++++----- api/v2/shared_types.go | 2 +- .../bases/apisix.apache.org_apisixroutes.yaml | 8 ++++---- test/e2e/crds/v2/route.go | 16 ++++++++-------- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index 00387853..f8144e21 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -313,7 +313,7 @@ func (exprs ApisixRouteHTTPMatchExprs) ToVars() (result adc.Vars, err error) { 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, body]") + 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}) @@ -417,13 +417,15 @@ type ApisixRouteHTTPMatchExprSubject struct { // 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 + // "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, query parameter, cookie, variable, or body field. - // Required for all scopes except Path. - 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/shared_types.go b/api/v2/shared_types.go index c0a09776..d5fd043f 100644 --- a/api/v2/shared_types.go +++ b/api/v2/shared_types.go @@ -88,7 +88,7 @@ const ( 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, + // 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" ) diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index 272bbd22..73b67516 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -207,8 +207,9 @@ spec: properties: name: description: |- - Name is the name of the header, query parameter, cookie, variable, or body field. - Required for all scopes except Path. + 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: |- @@ -216,7 +217,7 @@ spec: 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 + "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 @@ -227,7 +228,6 @@ spec: - Body type: string required: - - name - scope type: object value: diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go index c4bc5be4..c017c6e7 100644 --- a/test/e2e/crds/v2/route.go +++ b/test/e2e/crds/v2/route.go @@ -330,7 +330,7 @@ spec: } Eventually(request).WithTimeout(20 * time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK)) - By("verify non-matching POST without action field returns 404") + By("verify non-matching POST with wrong action value returns 404") s.NewAPISIXClient().POST("/post"). WithFormField("action", "logout"). Expect().Status(http.StatusNotFound) @@ -339,7 +339,7 @@ spec: s.NewAPISIXClient().GET("/get").Expect().Status(http.StatusNotFound) }) - It("Test ApisixRoute match by body vars (JSON path)", func() { + It("Test ApisixRoute match by body vars (JSON nested path)", func() { const apisixRouteSpec = ` apiVersion: apisix.apache.org/v2 kind: ApisixRoute @@ -358,29 +358,29 @@ spec: exprs: - subject: scope: Body - name: model + name: model.version op: Equal value: gpt-4 backends: - serviceName: httpbin-service-e2e-test servicePort: 80 ` - By("apply ApisixRoute with Body scope JSON path expr") + 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=gpt-4 returns 200") + By("verify matching POST with JSON body {model: {version: gpt-4}} returns 200") request := func() int { return s.NewAPISIXClient().POST("/post"). - WithJSON(map[string]string{"model": "gpt-4"}). + 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 returns 404") + By("verify non-matching JSON body with wrong nested value returns 404") s.NewAPISIXClient().POST("/post"). - WithJSON(map[string]string{"model": "gpt-3"}). + WithJSON(map[string]any{"model": map[string]string{"version": "gpt-3"}}). Expect().Status(http.StatusNotFound) }) From 64ffc78ed5fd502d72abc92635c2bfe5aa04859b Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 14:54:13 +0800 Subject: [PATCH 05/16] feat: add CEL XValidation for ApisixRouteHTTPMatchExprSubject Add +kubebuilder:validation:XValidation rule requiring name to be non-empty when scope is not Path. Regenerate CRD YAML and update CRD reference docs accordingly. All unit and CEL tests pass. --- api/v2/apisixroute_types.go | 1 + api/v2/apisixroute_types_test.go | 59 +++++++++++++++++++ .../bases/apisix.apache.org_apisixroutes.yaml | 3 + docs/en/latest/reference/api-reference.md | 4 +- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index f8144e21..f28b24b4 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -412,6 +412,7 @@ 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. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index dc3dc269..102b7696 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -18,14 +18,73 @@ 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" ) 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{ { diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index 73b67516..dec92659 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -230,6 +230,9 @@ spec: required: - 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. diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index 0556b716..ae998b55 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -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. 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:_ From 800c57c44134dd606691d5df304a3cdb79929a61 Mon Sep 17 00:00:00 2001 From: rongxin Date: Sat, 9 May 2026 16:46:27 +0800 Subject: [PATCH 06/16] fix: use size(self.name) > 0 in CEL rule to avoid YAML quote issues Replace self.name != '' with size(self.name) > 0 in the XValidation rule. The trailing '' in a YAML plain scalar was being mangled by kustomize during make install, producing an invalid rule. Using size() avoids any string-literal quoting at line end. Update test to walk the parsed YAML structure instead of raw substring match. --- api/v2/apisixroute_types.go | 2 +- api/v2/apisixroute_types_test.go | 48 +++++++++++++++++-- .../bases/apisix.apache.org_apisixroutes.yaml | 3 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index f28b24b4..dc309342 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -412,7 +412,7 @@ 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" +// +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. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index 102b7696..fd51c465 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -31,7 +31,7 @@ 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 != ''` +const celSubjectRule = `self.scope == 'Path' || size(self.name) > 0` func evalCELSubjectRule(t *testing.T, scope, name string) bool { t.Helper() @@ -74,15 +74,53 @@ func TestCEL_SubjectRule_InCRD(t *testing.T) { var crd map[string]any require.NoError(t, yaml.Unmarshal(data, &crd)) + // Ensure no typographic/smart quotes crept in anywhere in the file. 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") + + // Walk the parsed CRD to extract the x-kubernetes-validations rule string directly, + // which is more robust than substring matching against the raw YAML (line-wrapping safe). + rule := extractXValidationRule(t, crd) + assert.Equal(t, celSubjectRule, rule, + "XValidation rule in CRD must match the expected CEL expression") +} + +// extractXValidationRule walks the parsed CRD map to find the first +// x-kubernetes-validations rule on the subject property of HTTP match exprs. +func extractXValidationRule(t *testing.T, crd map[string]any) string { + t.Helper() + // Path: spec.versions[0].schema.openAPIV3Schema + // .properties.spec.properties.http.items + // .properties.match.properties.exprs.items + // .properties.subject.x-kubernetes-validations[0].rule + get := func(m map[string]any, key string) map[string]any { + v, ok := m[key] + require.True(t, ok, "key %q not found", key) + mv, ok := v.(map[string]any) + require.True(t, ok, "key %q is not a map", key) + return mv + } + spec := get(crd, "spec") + versions := spec["versions"].([]any) + require.NotEmpty(t, versions) + schema := get(versions[0].(map[string]any), "schema") + root := get(schema, "openAPIV3Schema") + props := get(root, "properties") + specProps := get(get(props, "spec"), "properties") + httpItems := get(get(specProps, "http"), "items") + matchProps := get(get(get(httpItems, "properties"), "match"), "properties") + exprsItems := get(get(matchProps, "exprs"), "items") + subject := get(get(exprsItems, "properties"), "subject") + validations, ok := subject["x-kubernetes-validations"].([]any) + require.True(t, ok, "x-kubernetes-validations not found or not a list") + require.NotEmpty(t, validations) + first := validations[0].(map[string]any) + rule, ok := first["rule"].(string) + require.True(t, ok, "rule field not found or not a string") + return rule } func TestToVars_ScopeBody_SimpleField(t *testing.T) { diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index dec92659..c2ccaa96 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -232,7 +232,8 @@ spec: type: object x-kubernetes-validations: - message: name is required when scope is not Path - rule: self.scope == 'Path' || self.name != '' + rule: self.scope == 'Path' || size(self.name) > + 0 value: description: |- Value defines a single value to compare against the subject. From a32cacf55bb70d229a7150c82170cd6c79bfc5a4 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 11 May 2026 08:54:29 +0800 Subject: [PATCH 07/16] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/v2/apisixroute_types.go | 2 +- config/crd/bases/apisix.apache.org_apisixroutes.yaml | 2 +- docs/en/latest/reference/api-reference.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index dc309342..ccf4b10f 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -418,7 +418,7 @@ type ApisixRouteHTTPMatchExprSubject struct { // 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 + // "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"` diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index c2ccaa96..6990a3bf 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -217,7 +217,7 @@ spec: 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 + "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 diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index ae998b55..b619e497 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -1119,7 +1119,7 @@ ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expres | Field | Description | | --- | --- | -| `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. | +| `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. | From 92325fdbd23c09f51dbe961725da31992113280a Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 11 May 2026 09:01:34 +0800 Subject: [PATCH 08/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- config/crd/bases/apisix.apache.org_apisixroutes.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index 6990a3bf..0251b97f 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -217,7 +217,7 @@ spec: 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 + "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 From 271976c9245485a6f65f58f07150f2b311e2aa55 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 04:25:51 +0800 Subject: [PATCH 09/16] feat: support Body scope in ApisixRoute HTTP match expressions - Add ScopeBody constant mapping to APISIX post_arg. variable - Add CEL validation requiring non-empty name for non-Path scopes - Add CRD schema validation tests using native Kubernetes CRD schema - Share CRD schema validator between ApisixConsumer and ApisixRoute tests - Adapt consumer tests for API7 Enterprise (supports all JWT algorithms) - Fix doc links to use public APISIX variable reference - Remove open-source-only comments from enterprise CRD --- api/v2/apisixconsumer_validation_test.go | 184 +++++++++++++ api/v2/apisixroute_types.go | 3 +- api/v2/apisixroute_types_test.go | 257 +++++++----------- api/v2/crd_schema_validator_test.go | 98 +++++++ .../bases/apisix.apache.org_apisixroutes.yaml | 3 +- 5 files changed, 384 insertions(+), 161 deletions(-) create mode 100644 api/v2/apisixconsumer_validation_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 new file mode 100644 index 00000000..b0113090 --- /dev/null +++ b/api/v2/apisixconsumer_validation_test.go @@ -0,0 +1,184 @@ +// 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" + + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" +) + +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") + return loadCRDSchema(t, crdPath) +} + +func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Secret: "my-secret", + Algorithm: "HS256", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Secret: "my-secret", + Algorithm: "HS512", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Secret: "my-secret", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + PublicKey: "test-public-key", + Algorithm: "RS256", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + PrivateKey: "test-private-key", + Algorithm: "RS256", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + PublicKey: "test-public-key", + PrivateKey: "test-private-key", + Algorithm: "RS256", + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} + +// TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey verifies that RS256 +// without any key is allowed in API7 Enterprise (supports all algorithms without key constraints). +func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Algorithm: "RS256", + }, + }, + }, + }, + } + // API7 Enterprise supports all algorithms; no key constraint enforced by CRD. + assert.NoError(t, v.validateObject(t, ac)) +} + +// TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric verifies that an +// explicitly empty algorithm string is treated the same as an unset algorithm +// (defaults to HS256) and does not require public_key or private_key. +func TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Secret: "my-secret", + // Algorithm is explicitly empty string — should be treated as + // unset and not require asymmetric keys. + }, + }, + }, + }, + } + assert.NoError(t, v.validateObject(t, ac)) +} diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index ccf4b10f..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. diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index fd51c465..5f88d9b6 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -1,184 +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 +// 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 +// 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. +// 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 +package v2_test import ( - "os" + "path/filepath" + "runtime" "testing" - "github.com/google/cel-go/cel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "sigs.k8s.io/yaml" -) - -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' || size(self.name) > 0` - -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)) + "k8s.io/apimachinery/pkg/util/intstr" - // Ensure no typographic/smart quotes crept in anywhere in the file. - raw := string(data) - 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") - - // Walk the parsed CRD to extract the x-kubernetes-validations rule string directly, - // which is more robust than substring matching against the raw YAML (line-wrapping safe). - rule := extractXValidationRule(t, crd) - assert.Equal(t, celSubjectRule, rule, - "XValidation rule in CRD must match the expected CEL expression") -} + apisixv2 "github.com/apache/apisix-ingress-controller/api/v2" +) -// extractXValidationRule walks the parsed CRD map to find the first -// x-kubernetes-validations rule on the subject property of HTTP match exprs. -func extractXValidationRule(t *testing.T, crd map[string]any) string { +func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator { t.Helper() - // Path: spec.versions[0].schema.openAPIV3Schema - // .properties.spec.properties.http.items - // .properties.match.properties.exprs.items - // .properties.subject.x-kubernetes-validations[0].rule - get := func(m map[string]any, key string) map[string]any { - v, ok := m[key] - require.True(t, ok, "key %q not found", key) - mv, ok := v.(map[string]any) - require.True(t, ok, "key %q is not a map", key) - return mv - } - spec := get(crd, "spec") - versions := spec["versions"].([]any) - require.NotEmpty(t, versions) - schema := get(versions[0].(map[string]any), "schema") - root := get(schema, "openAPIV3Schema") - props := get(root, "properties") - specProps := get(get(props, "spec"), "properties") - httpItems := get(get(specProps, "http"), "items") - matchProps := get(get(get(httpItems, "properties"), "match"), "properties") - exprsItems := get(get(matchProps, "exprs"), "items") - subject := get(get(exprsItems, "properties"), "subject") - validations, ok := subject["x-kubernetes-validations"].([]any) - require.True(t, ok, "x-kubernetes-validations not found or not a list") - require.NotEmpty(t, validations) - first := validations[0].(map[string]any) - rule, ok := first["rule"].(string) - require.True(t, ok, "rule field not found or not a string") - return rule + _, thisFile, _, _ := runtime.Caller(0) + crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..", + "config", "crd", "bases", "apisix.apache.org_apisixroutes.yaml") + return loadCRDSchema(t, crdPath) } -func TestToVars_ScopeBody_SimpleField(t *testing.T) { - exprs := ApisixRouteHTTPMatchExprs{ - { - Subject: ApisixRouteHTTPMatchExprSubject{ - Scope: ScopeBody, - Name: "action", +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)}, + }, + }, }, - 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"), - }, - } +// 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.validateObject(t, newRouteWithBodyExpr("apisix", "action", "login"))) +} - vars, err := exprs.ToVars() - require.NoError(t, err) - require.Len(t, vars, 1) +// 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.validateObject(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4"))) +} - // Should map to post_arg.model.version (dot-notation passthrough) - assert.Equal(t, "post_arg.model.version", vars[0][0].StrVal) +// 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.validateObject(t, newRouteWithBodyExpr("apisix", "", "login")) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required when scope is not Path") } -func TestToVars_ScopeBody_EmptyName_ReturnsError(t *testing.T) { - exprs := ApisixRouteHTTPMatchExprs{ - { - Subject: ApisixRouteHTTPMatchExprSubject{ - Scope: ScopeBody, - Name: "", +// 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)}, + }, + }, }, - Op: OpEqual, - Value: strPtr("login"), }, } - - _, err := exprs.ToVars() - assert.Error(t, err) - assert.Contains(t, err.Error(), "empty subject.name") + assert.NoError(t, v.validateObject(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..4e6671b6 --- /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 +} + +// validateObject marshals obj to JSON then runs the CRD's OpenAPI schema validator +// followed by any CEL x-kubernetes-validations rules. +func (v *crdSchemaValidator) validateObject(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/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index 0251b97f..f692629e 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -203,7 +203,7 @@ 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: |- @@ -249,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: |- From ce311b2c0f75c262ae1e98d558a077ca1b3946a4 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 05:11:47 +0800 Subject: [PATCH 10/16] refactor: rename validateObject to Validate, restore missing consumer tests, update CRD docs - Rename crdSchemaValidator.validateObject -> Validate (exported, cleaner API) - Restore 4 consumer JWT tests that were incorrectly removed: AsymmetricWithWhitespaceOnlyPublicKey, AsymmetricES256WithoutAnyKey, AsymmetricEdDSAWithoutAnyKey, AsymmetricWithEmptyPublicKey (enterprise CRD gained this CEL rule via master #406) - Regenerate api-reference.md (CRD docs check was failing in CI) --- api/v2/apisixconsumer_validation_test.go | 98 +++++++++++++++++++++-- api/v2/apisixroute_types_test.go | 8 +- api/v2/crd_schema_validator_test.go | 4 +- docs/en/latest/reference/api-reference.md | 6 +- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/api/v2/apisixconsumer_validation_test.go b/api/v2/apisixconsumer_validation_test.go index 60493d3e..8a5a33e8 100644 --- a/api/v2/apisixconsumer_validation_test.go +++ b/api/v2/apisixconsumer_validation_test.go @@ -49,7 +49,30 @@ func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) +} + +// TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey verifies +// that a whitespace-only public_key is treated as absent and rejected for +// asymmetric algorithms. +func TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Algorithm: "RS256", + PublicKey: " ", + }, + }, + }, + }, + } + err := v.Validate(t, ac) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512") } func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) { @@ -67,7 +90,7 @@ func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) { @@ -84,7 +107,7 @@ func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) { @@ -102,7 +125,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) { @@ -120,7 +143,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) { @@ -139,7 +162,7 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } // TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey verifies that RS256 @@ -158,7 +181,66 @@ func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) { }, }, } - err := v.validateObject(t, ac) + err := v.Validate(t, ac) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512") +} + +func TestApisixConsumer_JwtAuth_AsymmetricES256WithoutAnyKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Algorithm: "ES256", + }, + }, + }, + }, + } + err := v.Validate(t, ac) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512") +} + +func TestApisixConsumer_JwtAuth_AsymmetricEdDSAWithoutAnyKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Algorithm: "EdDSA", + }, + }, + }, + }, + } + err := v.Validate(t, ac) + require.Error(t, err) + assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512") +} + +func TestApisixConsumer_JwtAuth_AsymmetricWithEmptyPublicKey(t *testing.T) { + v := loadApisixConsumerSchema(t) + ac := &apisixv2.ApisixConsumer{ + Spec: apisixv2.ApisixConsumerSpec{ + AuthParameter: apisixv2.ApisixConsumerAuthParameter{ + JwtAuth: &apisixv2.ApisixConsumerJwtAuth{ + Value: &apisixv2.ApisixConsumerJwtAuthValue{ + Key: "my-key", + Algorithm: "RS256", + // PublicKey is empty string — omitempty means it won't appear + // in the serialized JSON, same effect as not set + }, + }, + }, + }, + } + err := v.Validate(t, ac) require.Error(t, err) assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512") } @@ -182,5 +264,5 @@ func TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ac)) + assert.NoError(t, v.Validate(t, ac)) } diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index 5f88d9b6..d5093477 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -74,21 +74,21 @@ func newRouteWithBodyExpr(ingressClass, fieldName, value string) *apisixv2.Apisi // simple field name passes CRD schema validation. func TestApisixRoute_BodyScope_SimpleField(t *testing.T) { v := loadApisixRouteSchema(t) - assert.NoError(t, v.validateObject(t, newRouteWithBodyExpr("apisix", "action", "login"))) + 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.validateObject(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4"))) + 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.validateObject(t, newRouteWithBodyExpr("apisix", "", "login")) + err := v.Validate(t, newRouteWithBodyExpr("apisix", "", "login")) require.Error(t, err) assert.Contains(t, err.Error(), "name is required when scope is not Path") } @@ -123,5 +123,5 @@ func TestApisixRoute_PathScope_EmptyName(t *testing.T) { }, }, } - assert.NoError(t, v.validateObject(t, ar)) + 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 index 4e6671b6..0337b856 100644 --- a/api/v2/crd_schema_validator_test.go +++ b/api/v2/crd_schema_validator_test.go @@ -38,9 +38,9 @@ type crdSchemaValidator struct { internal *apiextensions.JSONSchemaProps } -// validateObject marshals obj to JSON then runs the CRD's OpenAPI schema validator +// Validate marshals obj to JSON then runs the CRD's OpenAPI schema validator // followed by any CEL x-kubernetes-validations rules. -func (v *crdSchemaValidator) validateObject(t *testing.T, obj any) error { +func (v *crdSchemaValidator) Validate(t *testing.T, obj any) error { t.Helper() data, err := json.Marshal(obj) diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md index ad565d0e..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. | @@ -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. | From a171b694108b59b590e05b81af3608a4156f6885 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 05:15:55 +0800 Subject: [PATCH 11/16] feat(api): add HealthCheck types to BackendTrafficPolicySpec --- api/v1alpha1/backendtrafficpolicy_types.go | 140 +++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/api/v1alpha1/backendtrafficpolicy_types.go b/api/v1alpha1/backendtrafficpolicy_types.go index fc998b66..81a93bc0 100644 --- a/api/v1alpha1/backendtrafficpolicy_types.go +++ b/api/v1alpha1/backendtrafficpolicy_types.go @@ -74,6 +74,13 @@ type BackendTrafficPolicySpec struct { // UpstreamHost specifies the host of the Upstream request. Used only if // passHost is set to `rewrite`. Host Hostname `json:"upstreamHost,omitempty" yaml:"upstreamHost,omitempty"` + + // HealthCheck defines active and passive health check configuration for + // the upstream backends. When configured, APISIX will probe backends + // (active) or monitor live traffic (passive) to detect and bypass + // unhealthy nodes. + // +optional + HealthCheck *HealthCheck `json:"healthCheck,omitempty" yaml:"healthCheck,omitempty"` } // LoadBalancer describes the load balancing parameters. @@ -125,6 +132,139 @@ type BackendTrafficPolicyList struct { Items []BackendTrafficPolicy `json:"items"` } +// HealthCheck defines the active and passive health check configuration for upstream nodes. +type HealthCheck struct { + // Active health checks proactively send requests to upstream nodes to determine their availability. + // +kubebuilder:validation:Required + Active *ActiveHealthCheck `json:"active" yaml:"active"` + // Passive health checks evaluate upstream health based on observed traffic (timeouts, errors). + // +kubebuilder:validation:Optional + Passive *PassiveHealthCheck `json:"passive,omitempty" yaml:"passive,omitempty"` +} + +// ActiveHealthCheck defines the active upstream health check configuration. +type ActiveHealthCheck struct { + // Type is the health check type. Can be `http`, `https`, or `tcp`. + // +kubebuilder:validation:Enum=http;https;tcp; + // +kubebuilder:default=http + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // Timeout sets health check timeout. + // +optional + Timeout metav1.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` + + // Concurrency sets the number of targets to be checked at the same time. + // +kubebuilder:validation:Minimum=0 + // +optional + Concurrency int `json:"concurrency,omitempty" yaml:"concurrency,omitempty"` + + // Host sets the upstream host used in the health check request. + // +optional + Host string `json:"host,omitempty" yaml:"host,omitempty"` + + // Port sets the port on the upstream node to probe. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + Port int32 `json:"port,omitempty" yaml:"port,omitempty"` + + // HTTPPath sets the HTTP path for the probe request. + // +optional + HTTPPath string `json:"httpPath,omitempty" yaml:"httpPath,omitempty"` + + // StrictTLS controls whether TLS certificate validation is enforced. + // +optional + StrictTLS *bool `json:"strictTLS,omitempty" yaml:"strictTLS,omitempty"` + + // RequestHeaders sets additional HTTP request headers for the probe. + // +optional + RequestHeaders []string `json:"requestHeaders,omitempty" yaml:"requestHeaders,omitempty"` + + // Healthy configures the thresholds for marking a node healthy. + // +optional + Healthy *ActiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + + // Unhealthy configures the thresholds for marking a node unhealthy. + // +optional + Unhealthy *ActiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// PassiveHealthCheck defines passive health check configuration based on observed traffic. +type PassiveHealthCheck struct { + // Type is the passive health check type. Can be `http`, `https`, or `tcp`. + // +kubebuilder:validation:Enum=http;https;tcp; + // +kubebuilder:default=http + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // Healthy defines conditions under which a node is considered healthy. + // +optional + Healthy *PassiveHealthCheckHealthy `json:"healthy,omitempty" yaml:"healthy,omitempty"` + + // Unhealthy defines conditions under which a node is considered unhealthy. + // +optional + Unhealthy *PassiveHealthCheckUnhealthy `json:"unhealthy,omitempty" yaml:"unhealthy,omitempty"` +} + +// ActiveHealthCheckHealthy defines thresholds for actively marking an upstream node healthy. +type ActiveHealthCheckHealthy struct { + PassiveHealthCheckHealthy `json:",inline" yaml:",inline"` + + // Interval defines the time between health check probes. + // Minimum is 1s. + Interval metav1.Duration `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// ActiveHealthCheckUnhealthy defines thresholds for actively marking an upstream node unhealthy. +type ActiveHealthCheckUnhealthy struct { + PassiveHealthCheckUnhealthy `json:",inline" yaml:",inline"` + + // Interval defines the time between health check probes. + // Minimum is 1s. + Interval metav1.Duration `json:"interval,omitempty" yaml:"interval,omitempty"` +} + +// PassiveHealthCheckHealthy defines conditions for passively marking a node healthy. +type PassiveHealthCheckHealthy struct { + // HTTPCodes is the list of HTTP status codes considered healthy. + // +kubebuilder:validation:MinItems=1 + // +optional + HTTPCodes []int `json:"httpCodes,omitempty" yaml:"httpCodes,omitempty"` + + // Successes is the number of consecutive successful responses required to mark a node healthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + Successes int `json:"successes,omitempty" yaml:"successes,omitempty"` +} + +// PassiveHealthCheckUnhealthy defines conditions for passively marking a node unhealthy. +type PassiveHealthCheckUnhealthy struct { + // HTTPCodes is the list of HTTP status codes considered unhealthy. + // +kubebuilder:validation:MinItems=1 + // +optional + HTTPCodes []int `json:"httpCodes,omitempty" yaml:"httpCodes,omitempty"` + + // HTTPFailures is the number of HTTP failures to mark a node unhealthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + HTTPFailures int `json:"httpFailures,omitempty" yaml:"httpFailures,omitempty"` + + // TCPFailures is the number of TCP failures to mark a node unhealthy. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=254 + // +optional + TCPFailures int `json:"tcpFailures,omitempty" yaml:"tcpFailures,omitempty"` + + // Timeouts is the number of timeouts to mark a node unhealthy. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=254 + // +optional + Timeouts int `json:"timeout,omitempty" yaml:"timeout,omitempty"` +} + func init() { SchemeBuilder.Register(&BackendTrafficPolicy{}, &BackendTrafficPolicyList{}) } From f839117bcdf304c0c4f1b90367c1f96af3aa5c42 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 05:16:58 +0800 Subject: [PATCH 12/16] chore: regenerate deepcopy for BackendTrafficPolicy health check types --- api/v1alpha1/zz_generated.deepcopy.go | 165 ++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index f7b5383c..473a7b28 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,76 @@ import ( "sigs.k8s.io/gateway-api/apis/v1alpha2" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheck) DeepCopyInto(out *ActiveHealthCheck) { + *out = *in + out.Timeout = in.Timeout + if in.StrictTLS != nil { + in, out := &in.StrictTLS, &out.StrictTLS + *out = new(bool) + **out = **in + } + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Healthy != nil { + in, out := &in.Healthy, &out.Healthy + *out = new(ActiveHealthCheckHealthy) + (*in).DeepCopyInto(*out) + } + if in.Unhealthy != nil { + in, out := &in.Unhealthy, &out.Unhealthy + *out = new(ActiveHealthCheckUnhealthy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheck. +func (in *ActiveHealthCheck) DeepCopy() *ActiveHealthCheck { + if in == nil { + return nil + } + out := new(ActiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheckHealthy) DeepCopyInto(out *ActiveHealthCheckHealthy) { + *out = *in + in.PassiveHealthCheckHealthy.DeepCopyInto(&out.PassiveHealthCheckHealthy) + out.Interval = in.Interval +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheckHealthy. +func (in *ActiveHealthCheckHealthy) DeepCopy() *ActiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(ActiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActiveHealthCheckUnhealthy) DeepCopyInto(out *ActiveHealthCheckUnhealthy) { + *out = *in + in.PassiveHealthCheckUnhealthy.DeepCopyInto(&out.PassiveHealthCheckUnhealthy) + out.Interval = in.Interval +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActiveHealthCheckUnhealthy. +func (in *ActiveHealthCheckUnhealthy) DeepCopy() *ActiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(ActiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AdminKeyAuth) DeepCopyInto(out *AdminKeyAuth) { *out = *in @@ -172,6 +242,11 @@ func (in *BackendTrafficPolicySpec) DeepCopyInto(out *BackendTrafficPolicySpec) *out = new(Timeout) **out = **in } + if in.HealthCheck != nil { + in, out := &in.HealthCheck, &out.HealthCheck + *out = new(HealthCheck) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendTrafficPolicySpec. @@ -617,6 +692,31 @@ func (in *HTTPRoutePolicySpec) DeepCopy() *HTTPRoutePolicySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheck) DeepCopyInto(out *HealthCheck) { + *out = *in + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = new(ActiveHealthCheck) + (*in).DeepCopyInto(*out) + } + if in.Passive != nil { + in, out := &in.Passive, &out.Passive + *out = new(PassiveHealthCheck) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheck. +func (in *HealthCheck) DeepCopy() *HealthCheck { + if in == nil { + return nil + } + out := new(HealthCheck) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) { *out = *in @@ -632,6 +732,71 @@ func (in *LoadBalancer) DeepCopy() *LoadBalancer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheck) DeepCopyInto(out *PassiveHealthCheck) { + *out = *in + if in.Healthy != nil { + in, out := &in.Healthy, &out.Healthy + *out = new(PassiveHealthCheckHealthy) + (*in).DeepCopyInto(*out) + } + if in.Unhealthy != nil { + in, out := &in.Unhealthy, &out.Unhealthy + *out = new(PassiveHealthCheckUnhealthy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheck. +func (in *PassiveHealthCheck) DeepCopy() *PassiveHealthCheck { + if in == nil { + return nil + } + out := new(PassiveHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheckHealthy) DeepCopyInto(out *PassiveHealthCheckHealthy) { + *out = *in + if in.HTTPCodes != nil { + in, out := &in.HTTPCodes, &out.HTTPCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheckHealthy. +func (in *PassiveHealthCheckHealthy) DeepCopy() *PassiveHealthCheckHealthy { + if in == nil { + return nil + } + out := new(PassiveHealthCheckHealthy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PassiveHealthCheckUnhealthy) DeepCopyInto(out *PassiveHealthCheckUnhealthy) { + *out = *in + if in.HTTPCodes != nil { + in, out := &in.HTTPCodes, &out.HTTPCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PassiveHealthCheckUnhealthy. +func (in *PassiveHealthCheckUnhealthy) DeepCopy() *PassiveHealthCheckUnhealthy { + if in == nil { + return nil + } + out := new(PassiveHealthCheckUnhealthy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Plugin) DeepCopyInto(out *Plugin) { *out = *in From 0c19a9db18c955687e3fefb97495c92e8f6f63b3 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 05:19:13 +0800 Subject: [PATCH 13/16] feat: translate BackendTrafficPolicy health checks to APISIX upstream --- internal/adc/translator/httproute_test.go | 161 ++++++++++++++++++++++ internal/adc/translator/policies.go | 80 +++++++++++ 2 files changed, 241 insertions(+) diff --git a/internal/adc/translator/httproute_test.go b/internal/adc/translator/httproute_test.go index 28fdea83..7b11e129 100644 --- a/internal/adc/translator/httproute_test.go +++ b/internal/adc/translator/httproute_test.go @@ -20,6 +20,7 @@ package translator import ( "context" "testing" + "time" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" @@ -32,6 +33,7 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/api/v1alpha1" apiv2 "github.com/apache/apisix-ingress-controller/api/v2" "github.com/apache/apisix-ingress-controller/internal/provider" @@ -148,3 +150,162 @@ func TestTranslateHTTPRouteUpstreamScheme(t *testing.T) { }) } } + +func TestAttachBackendTrafficPolicyHealthCheck(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + policy *v1alpha1.BackendTrafficPolicy + wantChecks *adctypes.UpstreamHealthCheck + }{ + { + name: "nil health check produces no checks", + policy: &v1alpha1.BackendTrafficPolicy{}, + wantChecks: nil, + }, + { + name: "active health check with all fields", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + Type: "http", + Timeout: metav1.Duration{Duration: 3 * time.Second}, + HTTPPath: "/healthz", + Concurrency: 10, + Host: "example.com", + Port: 8080, + StrictTLS: &trueVal, + RequestHeaders: []string{"X-Custom: value"}, + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 5 * time.Second}, + PassiveHealthCheckHealthy: v1alpha1.PassiveHealthCheckHealthy{ + HTTPCodes: []int{200, 201}, + Successes: 3, + }, + }, + Unhealthy: &v1alpha1.ActiveHealthCheckUnhealthy{ + Interval: metav1.Duration{Duration: 2 * time.Second}, + PassiveHealthCheckUnhealthy: v1alpha1.PassiveHealthCheckUnhealthy{ + HTTPCodes: []int{500, 503}, + HTTPFailures: 5, + TCPFailures: 2, + Timeouts: 3, + }, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "http", + Timeout: 3, + HTTPPath: "/healthz", + Concurrency: 10, + Host: "example.com", + Port: 8080, + HTTPSVerifyCertificate: true, + HTTPRequestHeaders: []string{"X-Custom: value"}, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 5, + UpstreamPassiveHealthCheckHealthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: []int{200, 201}, + Successes: 3, + }, + }, + Unhealthy: adctypes.UpstreamActiveHealthCheckUnhealthy{ + Interval: 2, + UpstreamPassiveHealthCheckUnhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: []int{500, 503}, + HTTPFailures: 5, + TCPFailures: 2, + Timeouts: 3, + }, + }, + }, + }, + }, + { + name: "strictTLS false disables certificate verification", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + StrictTLS: &falseVal, + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "http", + HTTPSVerifyCertificate: false, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 1, + }, + }, + }, + }, + { + name: "active and passive health checks together", + policy: &v1alpha1.BackendTrafficPolicy{ + Spec: v1alpha1.BackendTrafficPolicySpec{ + HealthCheck: &v1alpha1.HealthCheck{ + Active: &v1alpha1.ActiveHealthCheck{ + Type: "tcp", + Healthy: &v1alpha1.ActiveHealthCheckHealthy{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + }, + Passive: &v1alpha1.PassiveHealthCheck{ + Type: "http", + Healthy: &v1alpha1.PassiveHealthCheckHealthy{ + HTTPCodes: []int{200}, + Successes: 2, + }, + Unhealthy: &v1alpha1.PassiveHealthCheckUnhealthy{ + HTTPCodes: []int{500}, + HTTPFailures: 3, + }, + }, + }, + }, + }, + wantChecks: &adctypes.UpstreamHealthCheck{ + Active: &adctypes.UpstreamActiveHealthCheck{ + Type: "tcp", + HTTPSVerifyCertificate: true, + Healthy: adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: 1, + }, + }, + Passive: &adctypes.UpstreamPassiveHealthCheck{ + Type: "http", + Healthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: []int{200}, + Successes: 2, + }, + Unhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: []int{500}, + HTTPFailures: 3, + }, + }, + }, + }, + } + + translator := &Translator{Log: logr.Discard()} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ups := adctypes.NewDefaultUpstream() + translator.attachBackendTrafficPolicyToUpstream(tt.policy, ups) + assert.Equal(t, tt.wantChecks, ups.Checks) + }) + } +} diff --git a/internal/adc/translator/policies.go b/internal/adc/translator/policies.go index 41706964..ef9a7795 100644 --- a/internal/adc/translator/policies.go +++ b/internal/adc/translator/policies.go @@ -79,4 +79,84 @@ func (t *Translator) attachBackendTrafficPolicyToUpstream(policy *v1alpha1.Backe upstream.HashOn = policy.Spec.LoadBalancer.HashOn upstream.Key = policy.Spec.LoadBalancer.Key } + if policy.Spec.HealthCheck != nil { + upstream.Checks = translateBTPHealthCheck(policy.Spec.HealthCheck) + } +} + +func translateBTPHealthCheck(hc *v1alpha1.HealthCheck) *adctypes.UpstreamHealthCheck { + if hc == nil || (hc.Active == nil && hc.Passive == nil) { + return nil + } + result := &adctypes.UpstreamHealthCheck{} + if hc.Active != nil { + result.Active = translateBTPActiveHealthCheck(hc.Active) + } + if hc.Passive != nil { + result.Passive = translateBTPPassiveHealthCheck(hc.Passive) + } + return result +} + +func translateBTPActiveHealthCheck(config *v1alpha1.ActiveHealthCheck) *adctypes.UpstreamActiveHealthCheck { + t := config.Type + if t == "" { + t = "http" + } + active := &adctypes.UpstreamActiveHealthCheck{ + Type: t, + Timeout: int(config.Timeout.Seconds()), + Concurrency: config.Concurrency, + Host: config.Host, + Port: config.Port, + HTTPPath: config.HTTPPath, + HTTPSVerifyCertificate: config.StrictTLS == nil || *config.StrictTLS, + HTTPRequestHeaders: config.RequestHeaders, + } + if config.Healthy != nil { + active.Healthy = adctypes.UpstreamActiveHealthCheckHealthy{ + Interval: int(config.Healthy.Interval.Seconds()), + UpstreamPassiveHealthCheckHealthy: adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: config.Healthy.HTTPCodes, + Successes: config.Healthy.Successes, + }, + } + } + if config.Unhealthy != nil { + active.Unhealthy = adctypes.UpstreamActiveHealthCheckUnhealthy{ + Interval: int(config.Unhealthy.Interval.Seconds()), + UpstreamPassiveHealthCheckUnhealthy: adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: config.Unhealthy.HTTPCodes, + HTTPFailures: config.Unhealthy.HTTPFailures, + TCPFailures: config.Unhealthy.TCPFailures, + Timeouts: config.Unhealthy.Timeouts, + }, + } + } + return active +} + +func translateBTPPassiveHealthCheck(config *v1alpha1.PassiveHealthCheck) *adctypes.UpstreamPassiveHealthCheck { + t := config.Type + if t == "" { + t = "http" + } + passive := &adctypes.UpstreamPassiveHealthCheck{ + Type: t, + } + if config.Healthy != nil { + passive.Healthy = adctypes.UpstreamPassiveHealthCheckHealthy{ + HTTPStatuses: config.Healthy.HTTPCodes, + Successes: config.Healthy.Successes, + } + } + if config.Unhealthy != nil { + passive.Unhealthy = adctypes.UpstreamPassiveHealthCheckUnhealthy{ + HTTPStatuses: config.Unhealthy.HTTPCodes, + HTTPFailures: config.Unhealthy.HTTPFailures, + TCPFailures: config.Unhealthy.TCPFailures, + Timeouts: config.Unhealthy.Timeouts, + } + } + return passive } From a03d90df72fe5db2f4698b2aa83f83e8f32bc717 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 05:20:42 +0800 Subject: [PATCH 14/16] chore: regenerate CRD manifests with BackendTrafficPolicy health check fields --- ...six.apache.org_backendtrafficpolicies.yaml | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml index 64c366a4..52047d34 100644 --- a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml +++ b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml @@ -42,6 +42,181 @@ spec: BackendTrafficPolicySpec defines traffic handling policies applied to backend services, such as load balancing strategy, connection settings, and failover behavior. properties: + healthCheck: + description: |- + HealthCheck defines active and passive health check configuration for + the upstream backends. When configured, APISIX will probe backends + (active) or monitor live traffic (passive) to detect and bypass + unhealthy nodes. + properties: + active: + description: Active health checks proactively send requests to + upstream nodes to determine their availability. + properties: + concurrency: + description: Concurrency sets the number of targets to be + checked at the same time. + minimum: 0 + type: integer + healthy: + description: Healthy configures the thresholds for marking + a node healthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered healthy. + items: + type: integer + minItems: 1 + type: array + interval: + description: |- + Interval defines the time between health check probes. + Minimum is 1s. + type: string + successes: + description: Successes is the number of consecutive successful + responses required to mark a node healthy. + maximum: 254 + minimum: 0 + type: integer + type: object + host: + description: Host sets the upstream host used in the health + check request. + type: string + httpPath: + description: HTTPPath sets the HTTP path for the probe request. + type: string + port: + description: Port sets the port on the upstream node to probe. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + requestHeaders: + description: RequestHeaders sets additional HTTP request headers + for the probe. + items: + type: string + type: array + strictTLS: + description: StrictTLS controls whether TLS certificate validation + is enforced. + type: boolean + timeout: + description: Timeout sets health check timeout. + type: string + type: + default: http + description: Type is the health check type. Can be `http`, + `https`, or `tcp`. + enum: + - http + - https + - tcp + type: string + unhealthy: + description: Unhealthy configures the thresholds for marking + a node unhealthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered unhealthy. + items: + type: integer + minItems: 1 + type: array + httpFailures: + description: HTTPFailures is the number of HTTP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + interval: + description: |- + Interval defines the time between health check probes. + Minimum is 1s. + type: string + tcpFailures: + description: TCPFailures is the number of TCP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + timeout: + description: Timeouts is the number of timeouts to mark + a node unhealthy. + maximum: 254 + minimum: 1 + type: integer + type: object + type: object + passive: + description: Passive health checks evaluate upstream health based + on observed traffic (timeouts, errors). + properties: + healthy: + description: Healthy defines conditions under which a node + is considered healthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered healthy. + items: + type: integer + minItems: 1 + type: array + successes: + description: Successes is the number of consecutive successful + responses required to mark a node healthy. + maximum: 254 + minimum: 0 + type: integer + type: object + type: + default: http + description: Type is the passive health check type. Can be + `http`, `https`, or `tcp`. + enum: + - http + - https + - tcp + type: string + unhealthy: + description: Unhealthy defines conditions under which a node + is considered unhealthy. + properties: + httpCodes: + description: HTTPCodes is the list of HTTP status codes + considered unhealthy. + items: + type: integer + minItems: 1 + type: array + httpFailures: + description: HTTPFailures is the number of HTTP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + tcpFailures: + description: TCPFailures is the number of TCP failures + to mark a node unhealthy. + maximum: 254 + minimum: 0 + type: integer + timeout: + description: Timeouts is the number of timeouts to mark + a node unhealthy. + maximum: 254 + minimum: 1 + type: integer + type: object + type: object + required: + - active + type: object loadbalancer: description: |- LoadBalancer represents the load balancer configuration for Kubernetes Service. From 9eecf12dcf551db5b4e834abd92686022d04a144 Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 13:06:37 +0800 Subject: [PATCH 15/16] fix: use trim() in CEL rule to reject whitespace-only name for non-Path scopes Addresses reviewer feedback: the previous rule 'size(self.name) > 0' would accept names like ' ' which would produce an invalid APISIX variable 'post_arg. '. Using trim() ensures blank-only names are rejected. The has(self.name) guard is not needed because non-nullable string fields resolve to "" (zero value) in Kubernetes CEL when omitted, as confirmed by existing tests. --- api/v2/apisixroute_types.go | 2 +- api/v2/apisixroute_types_test.go | 11 +- .../bases/apisix.apache.org_apisixroutes.yaml | 7 +- docs/en/latest/reference/api-reference.md | 130 ++++++++++++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index 0f6ad5a5..2294c78b 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -411,7 +411,7 @@ 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" +// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || size(self.name.trim()) > 0",message="name is required and must not be blank when scope is not Path" type ApisixRouteHTTPMatchExprSubject struct { // Scope specifies the subject scope. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index d5093477..714e2a40 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -90,7 +90,16 @@ 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") + assert.Contains(t, err.Error(), "name is required and must not be blank when scope is not Path") +} + +// TestApisixRoute_BodyScope_WhitespaceName verifies that a Body scope expr with +// a whitespace-only name is rejected (trim() prevents blank names from passing). +func TestApisixRoute_BodyScope_WhitespaceName(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 and must not be blank when scope is not Path") } // TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index f692629e..31c3abcd 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -231,9 +231,10 @@ spec: - scope type: object x-kubernetes-validations: - - message: name is required when scope is not Path - rule: self.scope == 'Path' || size(self.name) > - 0 + - message: name is required and must not be blank + when scope is not Path + rule: self.scope == 'Path' || size(self.name.trim()) + > 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 3be2f998..21eee1f3 100644 --- a/docs/en/latest/reference/api-reference.md +++ b/docs/en/latest/reference/api-reference.md @@ -103,6 +103,66 @@ PluginConfig defines plugin configuration. ### Types This section describes the types used by the CRDs. +#### ActiveHealthCheck + + +ActiveHealthCheck defines the active upstream health check configuration. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type is the health check type. Can be `http`, `https`, or `tcp`. | +| `timeout` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Timeout sets health check timeout. | +| `concurrency` _integer_ | Concurrency sets the number of targets to be checked at the same time. | +| `host` _string_ | Host sets the upstream host used in the health check request. | +| `port` _integer_ | Port sets the port on the upstream node to probe. | +| `httpPath` _string_ | HTTPPath sets the HTTP path for the probe request. | +| `strictTLS` _boolean_ | StrictTLS controls whether TLS certificate validation is enforced. | +| `requestHeaders` _string array_ | RequestHeaders sets additional HTTP request headers for the probe. | +| `healthy` _[ActiveHealthCheckHealthy](#activehealthcheckhealthy)_ | Healthy configures the thresholds for marking a node healthy. | +| `unhealthy` _[ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy)_ | Unhealthy configures the thresholds for marking a node unhealthy. | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### ActiveHealthCheckHealthy + + +ActiveHealthCheckHealthy defines thresholds for actively marking an upstream node healthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered healthy. | +| `successes` _integer_ | Successes is the number of consecutive successful responses required to mark a node healthy. | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Interval defines the time between health check probes. Minimum is 1s. | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + +#### ActiveHealthCheckUnhealthy + + +ActiveHealthCheckUnhealthy defines thresholds for actively marking an upstream node unhealthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered unhealthy. | +| `httpFailures` _integer_ | HTTPFailures is the number of HTTP failures to mark a node unhealthy. | +| `tcpFailures` _integer_ | TCPFailures is the number of TCP failures to mark a node unhealthy. | +| `timeout` _integer_ | Timeouts is the number of timeouts to mark a node unhealthy. | +| `interval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#duration-v1-meta)_ | Interval defines the time between health check probes. Minimum is 1s. | + + +_Appears in:_ +- [ActiveHealthCheck](#activehealthcheck) + #### AdminKeyAuth @@ -180,6 +240,7 @@ _Appears in:_ | `timeout` _[Timeout](#timeout)_ | Timeout sets the read, send, and connect timeouts to the upstream. | | `passHost` _string_ | PassHost configures how the host header should be determined when a request is forwarded to the upstream. Default is `pass`. Can be `pass`, `node` or `rewrite`:
• `pass`: preserve the original Host header
• `node`: use the upstream node’s host
• `rewrite`: set to a custom host via `upstreamHost` | | `upstreamHost` _[Hostname](#hostname)_ | UpstreamHost specifies the host of the Upstream request. Used only if passHost is set to `rewrite`. | +| `healthCheck` _[HealthCheck](#healthcheck)_ | HealthCheck defines active and passive health check configuration for the upstream backends. When configured, APISIX will probe backends (active) or monitor live traffic (passive) to detect and bypass unhealthy nodes. | _Appears in:_ @@ -344,6 +405,22 @@ HTTPRoutePolicySpec defines the desired state of HTTPRoutePolicy. _Appears in:_ - [HTTPRoutePolicy](#httproutepolicy) +#### HealthCheck + + +HealthCheck defines the active and passive health check configuration for upstream nodes. + + + +| Field | Description | +| --- | --- | +| `active` _[ActiveHealthCheck](#activehealthcheck)_ | Active health checks proactively send requests to upstream nodes to determine their availability. | +| `passive` _[PassiveHealthCheck](#passivehealthcheck)_ | Passive health checks evaluate upstream health based on observed traffic (timeouts, errors). | + + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + #### Hostname _Base type:_ `string` @@ -373,6 +450,59 @@ LoadBalancer describes the load balancing parameters. _Appears in:_ - [BackendTrafficPolicySpec](#backendtrafficpolicyspec) +#### PassiveHealthCheck + + +PassiveHealthCheck defines passive health check configuration based on observed traffic. + + + +| Field | Description | +| --- | --- | +| `type` _string_ | Type is the passive health check type. Can be `http`, `https`, or `tcp`. | +| `healthy` _[PassiveHealthCheckHealthy](#passivehealthcheckhealthy)_ | Healthy defines conditions under which a node is considered healthy. | +| `unhealthy` _[PassiveHealthCheckUnhealthy](#passivehealthcheckunhealthy)_ | Unhealthy defines conditions under which a node is considered unhealthy. | + + +_Appears in:_ +- [HealthCheck](#healthcheck) + +#### PassiveHealthCheckHealthy + + +PassiveHealthCheckHealthy defines conditions for passively marking a node healthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered healthy. | +| `successes` _integer_ | Successes is the number of consecutive successful responses required to mark a node healthy. | + + +_Appears in:_ +- [ActiveHealthCheckHealthy](#activehealthcheckhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + +#### PassiveHealthCheckUnhealthy + + +PassiveHealthCheckUnhealthy defines conditions for passively marking a node unhealthy. + + + +| Field | Description | +| --- | --- | +| `httpCodes` _integer array_ | HTTPCodes is the list of HTTP status codes considered unhealthy. | +| `httpFailures` _integer_ | HTTPFailures is the number of HTTP failures to mark a node unhealthy. | +| `tcpFailures` _integer_ | TCPFailures is the number of TCP failures to mark a node unhealthy. | +| `timeout` _integer_ | Timeouts is the number of timeouts to mark a node unhealthy. | + + +_Appears in:_ +- [ActiveHealthCheckUnhealthy](#activehealthcheckunhealthy) +- [PassiveHealthCheck](#passivehealthcheck) + #### Plugin From 3cbb4ca91e598ee087812625778dcc4e54bc21cc Mon Sep 17 00:00:00 2001 From: rongxin Date: Tue, 12 May 2026 14:17:24 +0800 Subject: [PATCH 16/16] fix: replace trim() CEL rule with Pattern annotation to avoid cost budget error trim() in CEL has O(n) cost; without maxLength the estimator assumed unbounded string length and rejected the rule with '100x budget exceeded'. Split the concern into two zero-cost constraints: - CEL XValidation: 'self.scope == Path || size(self.name) > 0' (O(1), unchanged) - Pattern: '^$|.*\S.*' (OpenAPI schema level, no CEL cost) rejects whitespace-only names while allowing empty string (Path scope) --- api/v2/apisixroute_types.go | 3 ++- api/v2/apisixroute_types_test.go | 6 +++--- config/crd/bases/apisix.apache.org_apisixroutes.yaml | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go index 2294c78b..f26b1875 100644 --- a/api/v2/apisixroute_types.go +++ b/api/v2/apisixroute_types.go @@ -411,7 +411,7 @@ type ApisixRouteAuthenticationLDAPAuth struct { } // ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression. -// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || size(self.name.trim()) > 0",message="name is required and must not be blank when scope is not Path" +// +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. // Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`. @@ -425,6 +425,7 @@ type ApisixRouteHTTPMatchExprSubject struct { // 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 + // +kubebuilder:validation:Pattern=`^$|.*\S.*` Name string `json:"name,omitempty" yaml:"name,omitempty"` } diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go index 714e2a40..4784c288 100644 --- a/api/v2/apisixroute_types_test.go +++ b/api/v2/apisixroute_types_test.go @@ -90,16 +90,16 @@ 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 and must not be blank when scope is not Path") + assert.Contains(t, err.Error(), "name is required when scope is not Path") } // TestApisixRoute_BodyScope_WhitespaceName verifies that a Body scope expr with -// a whitespace-only name is rejected (trim() prevents blank names from passing). +// a whitespace-only name is rejected by the Pattern constraint on the name field. func TestApisixRoute_BodyScope_WhitespaceName(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 and must not be blank when scope is not Path") + assert.Contains(t, err.Error(), "name") } // TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml b/config/crd/bases/apisix.apache.org_apisixroutes.yaml index 31c3abcd..cc5d35b5 100644 --- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml +++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml @@ -210,6 +210,7 @@ spec: 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. + pattern: ^$|.*\S.* type: string scope: description: |- @@ -231,10 +232,9 @@ spec: - scope type: object x-kubernetes-validations: - - message: name is required and must not be blank - when scope is not Path - rule: self.scope == 'Path' || size(self.name.trim()) - > 0 + - 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.