Skip to content

Commit 45e38a1

Browse files
authored
feat: support Body scope in ApisixRoute HTTP match expressions (#415)
1 parent 79db059 commit 45e38a1

7 files changed

Lines changed: 274 additions & 90 deletions

File tree

api/v2/apisixconsumer_validation_test.go

Lines changed: 2 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -16,93 +16,22 @@
1616
package v2_test
1717

1818
import (
19-
"context"
20-
"encoding/json"
21-
"os"
2219
"path/filepath"
2320
"runtime"
2421
"testing"
2522

2623
"github.com/stretchr/testify/assert"
2724
"github.com/stretchr/testify/require"
28-
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
29-
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
30-
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
31-
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
32-
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
33-
celconfig "k8s.io/apiserver/pkg/apis/cel"
34-
sigsyaml "sigs.k8s.io/yaml"
3525

3626
apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
3727
)
3828

39-
// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer
40-
// and provides a Validate method for use in tests.
41-
type consumerSchemaValidator struct {
42-
structural *structuralschema.Structural
43-
internal *apiextensions.JSONSchemaProps
44-
}
45-
46-
func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error {
47-
t.Helper()
48-
49-
data, err := json.Marshal(ac)
50-
require.NoError(t, err, "failed to marshal ApisixConsumer")
51-
52-
var obj map[string]interface{}
53-
require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map")
54-
55-
schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
56-
require.NoError(t, err, "failed to build schema validator")
57-
58-
if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 {
59-
return errs.ToAggregate()
60-
}
61-
62-
celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
63-
celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget)
64-
if len(celErrs) > 0 {
65-
return celErrs.ToAggregate()
66-
}
67-
return nil
68-
}
69-
70-
// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a
71-
// validator backed by the real generated schema.
72-
func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator {
29+
func loadApisixConsumerSchema(t *testing.T) *crdSchemaValidator {
7330
t.Helper()
74-
7531
_, thisFile, _, _ := runtime.Caller(0)
7632
crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
7733
"config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml")
78-
79-
data, err := os.ReadFile(crdPath)
80-
require.NoError(t, err, "failed to read CRD file: %s", crdPath)
81-
82-
jsonData, err := sigsyaml.YAMLToJSON(data)
83-
require.NoError(t, err, "failed to convert CRD YAML to JSON")
84-
85-
var crd apiextensionsv1.CustomResourceDefinition
86-
require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")
87-
88-
var v1Schema *apiextensionsv1.JSONSchemaProps
89-
for _, v := range crd.Spec.Versions {
90-
if v.Name == "v2" {
91-
v1Schema = v.Schema.OpenAPIV3Schema
92-
break
93-
}
94-
}
95-
require.NotNil(t, v1Schema, "v2 schema not found in CRD")
96-
97-
var internal apiextensions.JSONSchemaProps
98-
require.NoError(t,
99-
apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
100-
"failed to convert v1 schema to internal",
101-
)
102-
103-
structural, err := structuralschema.NewStructural(&internal)
104-
require.NoError(t, err, "failed to build structural schema")
105-
return &consumerSchemaValidator{structural: structural, internal: &internal}
34+
return loadCRDSchema(t, crdPath)
10635
}
10736

10837
func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) {

api/v2/apisixroute_types.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ type ApisixRouteHTTPMatch struct {
164164

165165
// FilterFunc is a user-defined function for advanced request filtering.
166166
// The function can use Nginx variables through the `vars` parameter.
167-
// This field is supported in APISIX but not in API7 Enterprise.
168167
FilterFunc string `json:"filter_func,omitempty" yaml:"filter_func,omitempty"`
169168
}
170169

@@ -266,7 +265,7 @@ type ApisixRouteStreamBackend struct {
266265
// ApisixRouteHTTPMatchExpr represents a binary expression used to match requests based on Nginx variables.
267266
type ApisixRouteHTTPMatchExpr struct {
268267
// Subject defines the left-hand side of the expression.
269-
// It can be any [built-in variable](/apisix/reference/built-in-variables) or string literal.
268+
// It can be any [APISIX variable](https://apisix.apache.org/docs/apisix/apisix-variable) or string literal.
270269
Subject ApisixRouteHTTPMatchExprSubject `json:"subject" yaml:"subject"`
271270

272271
// Op specifies the operator used in the expression.
@@ -310,8 +309,10 @@ func (exprs ApisixRouteHTTPMatchExprs) ToVars() (result adc.Vars, err error) {
310309
subj = "uri"
311310
case ScopeVariable:
312311
subj = expr.Subject.Name
312+
case ScopeBody:
313+
subj = "post_arg." + expr.Subject.Name
313314
default:
314-
return result, errors.New("invalid http match expr: subject.scope should be one of [query, header, cookie, path, variable]")
315+
return result, errors.New("invalid http match expr: subject.scope should be one of [Query, Header, Cookie, Path, Variable, Body]")
315316
}
316317
this.SliceVal = append(this.SliceVal, adc.StringOrSlice{StrVal: subj})
317318

@@ -410,12 +411,21 @@ type ApisixRouteAuthenticationLDAPAuth struct {
410411
}
411412

412413
// ApisixRouteHTTPMatchExprSubject describes the subject of a route matching expression.
414+
// +kubebuilder:validation:XValidation:rule="self.scope == 'Path' || size(self.name) > 0",message="name is required when scope is not Path"
413415
type ApisixRouteHTTPMatchExprSubject struct {
414-
// Scope specifies the subject scope and can be `Header`, `Query`, or `Path`.
416+
// Scope specifies the subject scope.
417+
// Supported values: `Header`, `Query`, `Path`, `Cookie`, `Variable`, `Body`.
415418
// When Scope is `Path`, Name will be ignored.
419+
// When Scope is `Body`, Name supports dot-notation JSON path (e.g., "model.version",
420+
// "messages[*].role") and maps to APISIX's `post_arg.<name>` variable, which works with
421+
// application/json, application/x-www-form-urlencoded, and multipart/form-data.
422+
// +kubebuilder:validation:Enum=Header;Query;Path;Cookie;Variable;Body
416423
Scope string `json:"scope" yaml:"scope"`
417-
// Name is the name of the header or query parameter.
418-
Name string `json:"name" yaml:"name"`
424+
// Name is the name of the subject within the given scope: the header name, query
425+
// parameter name, cookie name, Nginx variable name, or body field name (dot-notation
426+
// JSON path supported for Body scope). Optional when Scope is Path.
427+
// +kubebuilder:validation:Optional
428+
Name string `json:"name,omitempty" yaml:"name,omitempty"`
419429
}
420430

421431
func init() {

api/v2/apisixroute_types_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package v2_test
17+
18+
import (
19+
"path/filepath"
20+
"runtime"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
"k8s.io/apimachinery/pkg/util/intstr"
26+
27+
apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
28+
)
29+
30+
func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator {
31+
t.Helper()
32+
_, thisFile, _, _ := runtime.Caller(0)
33+
crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
34+
"config", "crd", "bases", "apisix.apache.org_apisixroutes.yaml")
35+
return loadCRDSchema(t, crdPath)
36+
}
37+
38+
func strPtr(s string) *string { return &s }
39+
func boolPtr(b bool) *bool { return &b }
40+
func intPtr(i int) *int { return &i }
41+
42+
func newRouteWithBodyExpr(ingressClass, fieldName, value string) *apisixv2.ApisixRoute {
43+
return &apisixv2.ApisixRoute{
44+
Spec: apisixv2.ApisixRouteSpec{
45+
IngressClassName: ingressClass,
46+
HTTP: []apisixv2.ApisixRouteHTTP{
47+
{
48+
Name: "rule0",
49+
Websocket: boolPtr(false),
50+
Match: apisixv2.ApisixRouteHTTPMatch{
51+
Paths: []string{"/*"},
52+
NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{
53+
{
54+
Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{
55+
Scope: apisixv2.ScopeBody,
56+
Name: fieldName,
57+
},
58+
Op: apisixv2.OpEqual,
59+
Set: []string{},
60+
Value: strPtr(value),
61+
},
62+
},
63+
},
64+
Backends: []apisixv2.ApisixRouteHTTPBackend{
65+
{ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)},
66+
},
67+
},
68+
},
69+
},
70+
}
71+
}
72+
73+
// TestApisixRoute_BodyScope_SimpleField verifies that a Body scope expr with a
74+
// simple field name passes CRD schema validation.
75+
func TestApisixRoute_BodyScope_SimpleField(t *testing.T) {
76+
v := loadApisixRouteSchema(t)
77+
assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "action", "login")))
78+
}
79+
80+
// TestApisixRoute_BodyScope_NestedJSONPath verifies that a Body scope expr with
81+
// a dot-notation JSON path passes CRD schema validation.
82+
func TestApisixRoute_BodyScope_NestedJSONPath(t *testing.T) {
83+
v := loadApisixRouteSchema(t)
84+
assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4")))
85+
}
86+
87+
// TestApisixRoute_BodyScope_EmptyName verifies that a Body scope expr with an
88+
// empty name is rejected by the CEL XValidation rule.
89+
func TestApisixRoute_BodyScope_EmptyName(t *testing.T) {
90+
v := loadApisixRouteSchema(t)
91+
err := v.Validate(t, newRouteWithBodyExpr("apisix", "", "login"))
92+
require.Error(t, err)
93+
assert.Contains(t, err.Error(), "name is required when scope is not Path")
94+
}
95+
96+
// TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name
97+
// passes CRD schema validation (name is optional for Path).
98+
func TestApisixRoute_PathScope_EmptyName(t *testing.T) {
99+
v := loadApisixRouteSchema(t)
100+
ar := &apisixv2.ApisixRoute{
101+
Spec: apisixv2.ApisixRouteSpec{
102+
HTTP: []apisixv2.ApisixRouteHTTP{
103+
{
104+
Name: "rule0",
105+
Websocket: boolPtr(false),
106+
Match: apisixv2.ApisixRouteHTTPMatch{
107+
Paths: []string{"/*"},
108+
NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{
109+
{
110+
Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{
111+
Scope: apisixv2.ScopePath,
112+
},
113+
Op: apisixv2.OpEqual,
114+
Set: []string{},
115+
Value: strPtr("/api"),
116+
},
117+
},
118+
},
119+
Backends: []apisixv2.ApisixRouteHTTPBackend{
120+
{ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)},
121+
},
122+
},
123+
},
124+
},
125+
}
126+
assert.NoError(t, v.Validate(t, ar))
127+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package v2_test
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"os"
22+
"testing"
23+
24+
"github.com/stretchr/testify/require"
25+
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
26+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
27+
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
28+
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
29+
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
30+
celconfig "k8s.io/apiserver/pkg/apis/cel"
31+
sigsyaml "sigs.k8s.io/yaml"
32+
)
33+
34+
// crdSchemaValidator holds the parsed CRD schema and validates objects against it,
35+
// including both OpenAPI structural validation and CEL x-kubernetes-validations rules.
36+
type crdSchemaValidator struct {
37+
structural *structuralschema.Structural
38+
internal *apiextensions.JSONSchemaProps
39+
}
40+
41+
// Validate marshals obj to JSON then runs the CRD's OpenAPI schema validator
42+
// followed by any CEL x-kubernetes-validations rules.
43+
func (v *crdSchemaValidator) Validate(t *testing.T, obj any) error {
44+
t.Helper()
45+
46+
data, err := json.Marshal(obj)
47+
require.NoError(t, err, "failed to marshal object")
48+
49+
var raw map[string]interface{}
50+
require.NoError(t, json.Unmarshal(data, &raw), "failed to unmarshal to map")
51+
52+
schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
53+
require.NoError(t, err, "failed to build schema validator")
54+
55+
if errs := validation.ValidateCustomResource(nil, raw, schemaValidator); len(errs) > 0 {
56+
return errs.ToAggregate()
57+
}
58+
59+
celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
60+
celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, raw, nil, celconfig.RuntimeCELCostBudget)
61+
if len(celErrs) > 0 {
62+
return celErrs.ToAggregate()
63+
}
64+
return nil
65+
}
66+
67+
// loadCRDSchema reads a CRD YAML file and returns a validator for the "v2" version schema.
68+
func loadCRDSchema(t *testing.T, crdPath string) *crdSchemaValidator {
69+
t.Helper()
70+
71+
data, err := os.ReadFile(crdPath)
72+
require.NoError(t, err, "failed to read CRD file: %s", crdPath)
73+
74+
jsonData, err := sigsyaml.YAMLToJSON(data)
75+
require.NoError(t, err, "failed to convert CRD YAML to JSON")
76+
77+
var crd apiextensionsv1.CustomResourceDefinition
78+
require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")
79+
80+
var v1Schema *apiextensionsv1.JSONSchemaProps
81+
for _, v := range crd.Spec.Versions {
82+
if v.Name == "v2" {
83+
v1Schema = v.Schema.OpenAPIV3Schema
84+
break
85+
}
86+
}
87+
require.NotNil(t, v1Schema, "v2 schema not found in CRD")
88+
89+
var internal apiextensions.JSONSchemaProps
90+
require.NoError(t,
91+
apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
92+
"failed to convert v1 schema to internal",
93+
)
94+
95+
structural, err := structuralschema.NewStructural(&internal)
96+
require.NoError(t, err, "failed to build structural schema")
97+
return &crdSchemaValidator{structural: structural, internal: &internal}
98+
}

api/v2/shared_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ const (
8686
ScopeCookie = "Cookie"
8787
// ScopeVariable means the route match expression subject is in variable.
8888
ScopeVariable = "Variable"
89+
// ScopeBody means the route match expression subject is in the request body.
90+
// Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role"),
91+
// and maps to APISIX's post_arg.<name> variable, which supports application/json,
92+
// application/x-www-form-urlencoded, and multipart/form-data content types.
93+
ScopeBody = "Body"
8994
)
9095

9196
const (

0 commit comments

Comments
 (0)