Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 2 additions & 73 deletions api/v2/apisixconsumer_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,93 +16,22 @@
package v2_test

import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
celconfig "k8s.io/apiserver/pkg/apis/cel"
sigsyaml "sigs.k8s.io/yaml"

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

// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer
// and provides a Validate method for use in tests.
type consumerSchemaValidator struct {
structural *structuralschema.Structural
internal *apiextensions.JSONSchemaProps
}

func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error {
t.Helper()

data, err := json.Marshal(ac)
require.NoError(t, err, "failed to marshal ApisixConsumer")

var obj map[string]interface{}
require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map")

schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
require.NoError(t, err, "failed to build schema validator")

if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 {
return errs.ToAggregate()
}

celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget)
if len(celErrs) > 0 {
return celErrs.ToAggregate()
}
return nil
}

// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a
// validator backed by the real generated schema.
func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator {
func loadApisixConsumerSchema(t *testing.T) *crdSchemaValidator {
t.Helper()

_, thisFile, _, _ := runtime.Caller(0)
crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
"config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml")

data, err := os.ReadFile(crdPath)
require.NoError(t, err, "failed to read CRD file: %s", crdPath)

jsonData, err := sigsyaml.YAMLToJSON(data)
require.NoError(t, err, "failed to convert CRD YAML to JSON")

var crd apiextensionsv1.CustomResourceDefinition
require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")

var v1Schema *apiextensionsv1.JSONSchemaProps
for _, v := range crd.Spec.Versions {
if v.Name == "v2" {
v1Schema = v.Schema.OpenAPIV3Schema
break
}
}
require.NotNil(t, v1Schema, "v2 schema not found in CRD")

var internal apiextensions.JSONSchemaProps
require.NoError(t,
apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
"failed to convert v1 schema to internal",
)

structural, err := structuralschema.NewStructural(&internal)
require.NoError(t, err, "failed to build structural schema")
return &consumerSchemaValidator{structural: structural, internal: &internal}
return loadCRDSchema(t, crdPath)
}

func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) {
Expand Down
22 changes: 16 additions & 6 deletions api/v2/apisixroute_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -310,8 +309,10 @@ func (exprs ApisixRouteHTTPMatchExprs) ToVars() (result adc.Vars, err error) {
subj = "uri"
case ScopeVariable:
subj = expr.Subject.Name
case ScopeBody:
subj = "post_arg." + expr.Subject.Name
default:
return result, errors.New("invalid http match expr: subject.scope should be one of [query, header, cookie, path, variable]")
return result, errors.New("invalid http match expr: subject.scope should be one of [Query, Header, Cookie, Path, Variable, Body]")
}
this.SliceVal = append(this.SliceVal, adc.StringOrSlice{StrVal: subj})

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

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

func init() {
Expand Down
127 changes: 127 additions & 0 deletions api/v2/apisixroute_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package v2_test

import (
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/intstr"

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

func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator {
t.Helper()
_, thisFile, _, _ := runtime.Caller(0)
crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
"config", "crd", "bases", "apisix.apache.org_apisixroutes.yaml")
return loadCRDSchema(t, crdPath)
}

func strPtr(s string) *string { return &s }
func boolPtr(b bool) *bool { return &b }
func intPtr(i int) *int { return &i }

func newRouteWithBodyExpr(ingressClass, fieldName, value string) *apisixv2.ApisixRoute {
return &apisixv2.ApisixRoute{
Spec: apisixv2.ApisixRouteSpec{
IngressClassName: ingressClass,
HTTP: []apisixv2.ApisixRouteHTTP{
{
Name: "rule0",
Websocket: boolPtr(false),
Match: apisixv2.ApisixRouteHTTPMatch{
Paths: []string{"/*"},
NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{
{
Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{
Scope: apisixv2.ScopeBody,
Name: fieldName,
},
Op: apisixv2.OpEqual,
Set: []string{},
Value: strPtr(value),
},
},
},
Backends: []apisixv2.ApisixRouteHTTPBackend{
{ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)},
},
},
},
},
}
}

// TestApisixRoute_BodyScope_SimpleField verifies that a Body scope expr with a
// simple field name passes CRD schema validation.
func TestApisixRoute_BodyScope_SimpleField(t *testing.T) {
v := loadApisixRouteSchema(t)
assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "action", "login")))
}

// TestApisixRoute_BodyScope_NestedJSONPath verifies that a Body scope expr with
// a dot-notation JSON path passes CRD schema validation.
func TestApisixRoute_BodyScope_NestedJSONPath(t *testing.T) {
v := loadApisixRouteSchema(t)
assert.NoError(t, v.Validate(t, newRouteWithBodyExpr("apisix", "model.version", "gpt-4")))
}

// TestApisixRoute_BodyScope_EmptyName verifies that a Body scope expr with an
// empty name is rejected by the CEL XValidation rule.
func TestApisixRoute_BodyScope_EmptyName(t *testing.T) {
v := loadApisixRouteSchema(t)
err := v.Validate(t, newRouteWithBodyExpr("apisix", "", "login"))
require.Error(t, err)
assert.Contains(t, err.Error(), "name is required when scope is not Path")
}

// TestApisixRoute_PathScope_EmptyName verifies that Path scope without a name
// passes CRD schema validation (name is optional for Path).
func TestApisixRoute_PathScope_EmptyName(t *testing.T) {
v := loadApisixRouteSchema(t)
ar := &apisixv2.ApisixRoute{
Spec: apisixv2.ApisixRouteSpec{
HTTP: []apisixv2.ApisixRouteHTTP{
{
Name: "rule0",
Websocket: boolPtr(false),
Match: apisixv2.ApisixRouteHTTPMatch{
Paths: []string{"/*"},
NginxVars: apisixv2.ApisixRouteHTTPMatchExprs{
{
Subject: apisixv2.ApisixRouteHTTPMatchExprSubject{
Scope: apisixv2.ScopePath,
},
Op: apisixv2.OpEqual,
Set: []string{},
Value: strPtr("/api"),
},
},
},
Backends: []apisixv2.ApisixRouteHTTPBackend{
{ServiceName: "my-svc", ServicePort: intstr.FromInt(80), Weight: intPtr(100)},
},
},
},
},
}
assert.NoError(t, v.Validate(t, ar))
}
98 changes: 98 additions & 0 deletions api/v2/crd_schema_validator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package v2_test

import (
"context"
"encoding/json"
"os"
"testing"

"github.com/stretchr/testify/require"
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
celconfig "k8s.io/apiserver/pkg/apis/cel"
sigsyaml "sigs.k8s.io/yaml"
)

// crdSchemaValidator holds the parsed CRD schema and validates objects against it,
// including both OpenAPI structural validation and CEL x-kubernetes-validations rules.
type crdSchemaValidator struct {
structural *structuralschema.Structural
internal *apiextensions.JSONSchemaProps
}

// Validate marshals obj to JSON then runs the CRD's OpenAPI schema validator
// followed by any CEL x-kubernetes-validations rules.
func (v *crdSchemaValidator) Validate(t *testing.T, obj any) error {
t.Helper()

data, err := json.Marshal(obj)
require.NoError(t, err, "failed to marshal object")

var raw map[string]interface{}
require.NoError(t, json.Unmarshal(data, &raw), "failed to unmarshal to map")

schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
require.NoError(t, err, "failed to build schema validator")

if errs := validation.ValidateCustomResource(nil, raw, schemaValidator); len(errs) > 0 {
return errs.ToAggregate()
}

celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, raw, nil, celconfig.RuntimeCELCostBudget)
if len(celErrs) > 0 {
return celErrs.ToAggregate()
}
return nil
}

// loadCRDSchema reads a CRD YAML file and returns a validator for the "v2" version schema.
func loadCRDSchema(t *testing.T, crdPath string) *crdSchemaValidator {
t.Helper()

data, err := os.ReadFile(crdPath)
require.NoError(t, err, "failed to read CRD file: %s", crdPath)

jsonData, err := sigsyaml.YAMLToJSON(data)
require.NoError(t, err, "failed to convert CRD YAML to JSON")

var crd apiextensionsv1.CustomResourceDefinition
require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")

var v1Schema *apiextensionsv1.JSONSchemaProps
for _, v := range crd.Spec.Versions {
if v.Name == "v2" {
v1Schema = v.Schema.OpenAPIV3Schema
break
}
}
require.NotNil(t, v1Schema, "v2 schema not found in CRD")

var internal apiextensions.JSONSchemaProps
require.NoError(t,
apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
"failed to convert v1 schema to internal",
)

structural, err := structuralschema.NewStructural(&internal)
require.NoError(t, err, "failed to build structural schema")
return &crdSchemaValidator{structural: structural, internal: &internal}
}
5 changes: 5 additions & 0 deletions api/v2/shared_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ const (
ScopeCookie = "Cookie"
// ScopeVariable means the route match expression subject is in variable.
ScopeVariable = "Variable"
// ScopeBody means the route match expression subject is in the request body.
// Name supports dot-notation JSON path (e.g., "model.version", "messages[*].role"),
// and maps to APISIX's post_arg.<name> variable, which supports application/json,
// application/x-www-form-urlencoded, and multipart/form-data content types.
ScopeBody = "Body"
)

const (
Expand Down
Loading
Loading