x: add numeric comparison operators (gt/ge/lt/le/eq/ne) to Header/Query/BodyJSON matchers

Adds a 3-parameter form to Header(), Query(), and BodyJSON() matchers
for numeric comparisons: Header("Content-Length", "gt", "1024"). Extracts
the duplicated comparison setup and dispatch into shared compareOp and
parseCompareParams helpers. Includes tests for all six operators across
header, query, and body JSON matchers.
This commit is contained in:
ginuerzh
2026-07-25 13:43:17 +08:00
parent 7a190938aa
commit 88b03e0721
2 changed files with 396 additions and 6 deletions
+108 -6
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"regexp"
"strconv"
"strings"
"unicode/utf8"
@@ -154,11 +155,11 @@ var httpFuncs = map[string]func(*matchersTree, ...string) error{
"Path": expectNParameters(path, 1),
"PathRegexp": expectNParameters(pathRegexp, 1),
"PathPrefix": expectNParameters(pathPrefix, 1),
"Header": expectNParameters(header, 1, 2),
"Header": expectNParameters(header, 1, 2, 3),
"HeaderRegexp": expectNParameters(headerRegexp, 1, 2),
"Query": expectNParameters(query, 1, 2),
"Query": expectNParameters(query, 1, 2, 3),
"QueryRegexp": expectNParameters(queryRegexp, 1, 2),
"BodyJSON": expectNParameters(bodyJSON, 2),
"BodyJSON": expectNParameters(bodyJSON, 2, 3),
"BodyRegexp": expectNParameters(bodyRegexp, 1),
"Bypass": expectNParameters(bypass, 1),
"Admission": expectNParameters(admission, 1),
@@ -336,9 +337,32 @@ func pathPrefix(tree *matchersTree, paths ...string) error {
func header(tree *matchersTree, headers ...string) error {
key := http.CanonicalHeaderKey(headers[0])
op, compVal, err := parseCompareParams(headers)
if err != nil {
return err
}
if op != "" {
tree.matcher = func(req *routing.Request) bool {
if req.Header == nil {
return false
}
for _, v := range req.Header[key] {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
continue
}
if compareOp(f, compVal, op) {
return true
}
}
return false
}
return nil
}
var value string
var hasValue bool
if len(headers) == 2 {
if len(headers) >= 2 {
value = headers[1]
hasValue = true
}
@@ -401,9 +425,32 @@ func headerRegexp(tree *matchersTree, headers ...string) error {
func query(tree *matchersTree, queries ...string) error {
key := queries[0]
op, compVal, err := parseCompareParams(queries)
if err != nil {
return err
}
if op != "" {
tree.matcher = func(req *routing.Request) bool {
if req.Query == nil {
return false
}
for _, v := range req.Query[key] {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
continue
}
if compareOp(f, compVal, op) {
return true
}
}
return false
}
return nil
}
var value string
var hasValue bool
if len(queries) == 2 {
if len(queries) >= 2 {
value = queries[1]
hasValue = true
}
@@ -476,9 +523,64 @@ func bodyRegexp(tree *matchersTree, patterns ...string) error {
return nil
}
func compareOp(f, compVal float64, op string) bool {
switch op {
case "gt":
return f > compVal
case "ge":
return f >= compVal
case "lt":
return f < compVal
case "le":
return f <= compVal
case "eq":
return f == compVal
case "ne":
return f != compVal
}
return false
}
func parseCompareParams(args []string) (op string, compVal float64, err error) {
if len(args) < 3 {
return
}
op = args[1]
switch op {
case "gt", "ge", "lt", "le", "eq", "ne":
default:
return "", 0, fmt.Errorf("unknown comparison operator %q", op)
}
v, err := strconv.ParseFloat(args[2], 64)
if err != nil {
return "", 0, fmt.Errorf("invalid compare value %q: %w", args[2], err)
}
return op, v, nil
}
func bodyJSON(tree *matchersTree, args ...string) error {
path := args[0]
re, err := regexp.Compile(args[1])
op, compVal, err := parseCompareParams(args)
if err != nil {
return err
}
if op != "" {
tree.matcher = func(req *routing.Request) bool {
if len(req.Body) == 0 {
return false
}
f, err := strconv.ParseFloat(gjson.GetBytes(req.Body, path).String(), 64)
if err != nil {
return false
}
return compareOp(f, compVal, op)
}
return nil
}
pattern := args[1]
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("compiling BodyJSON pattern: %w", err)
}
+288
View File
@@ -722,3 +722,291 @@ func TestMatcherBodyRegexp_CaseInsensitive(t *testing.T) {
req := &routing.Request{Body: []byte(`{"Content-Type": "application/json"}`)}
assert.True(t, m.Match(req))
}
func TestMatcherHeaderCompare_gt(t *testing.T) {
// 3-param form: Header(key, op, value) with op="gt"
m, err := NewMatcher(`Header("Content-Length", "gt", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"greater", http.Header{"Content-Length": {"2048"}}, true},
{"equal", http.Header{"Content-Length": {"1024"}}, false},
{"less", http.Header{"Content-Length": {"512"}}, false},
{"nil", nil, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherHeaderCompare_ge(t *testing.T) {
m, err := NewMatcher(`Header("Content-Length", "ge", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"greater", http.Header{"Content-Length": {"2048"}}, true},
{"equal", http.Header{"Content-Length": {"1024"}}, true},
{"less", http.Header{"Content-Length": {"512"}}, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherHeaderCompare_lt(t *testing.T) {
m, err := NewMatcher(`Header("Content-Length", "lt", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"less", http.Header{"Content-Length": {"512"}}, true},
{"equal", http.Header{"Content-Length": {"1024"}}, false},
{"greater", http.Header{"Content-Length": {"2048"}}, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherHeaderCompare_le(t *testing.T) {
m, err := NewMatcher(`Header("Content-Length", "le", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"less", http.Header{"Content-Length": {"512"}}, true},
{"equal", http.Header{"Content-Length": {"1024"}}, true},
{"greater", http.Header{"Content-Length": {"2048"}}, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherHeaderCompare_MultiValue(t *testing.T) {
m, err := NewMatcher(`Header("X-Values", "gt", "100")`)
require.NoError(t, err)
req := &routing.Request{
Header: http.Header{"X-Values": {"50", "200"}},
}
assert.True(t, m.Match(req), "should match when any value satisfies")
}
func TestMatcherHeaderCompare_NonNumeric(t *testing.T) {
m, err := NewMatcher(`Header("X-Value", "gt", "100")`)
require.NoError(t, err)
req := &routing.Request{
Header: http.Header{"X-Value": {"abc"}},
}
assert.False(t, m.Match(req), "non-numeric value should not match")
}
func TestMatcherHeaderCompare_Missing(t *testing.T) {
m, err := NewMatcher(`Header("X-Missing", "gt", "100")`)
require.NoError(t, err)
req := &routing.Request{Header: http.Header{}}
assert.False(t, m.Match(req))
}
func TestMatcherHeaderCompare_eq(t *testing.T) {
m, err := NewMatcher(`Header("Content-Length", "eq", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"equal", http.Header{"Content-Length": {"1024"}}, true},
{"not equal", http.Header{"Content-Length": {"2048"}}, false},
{"nil", nil, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherHeaderCompare_ne(t *testing.T) {
m, err := NewMatcher(`Header("Content-Length", "ne", "1024")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
want bool
}{
{"not equal", http.Header{"Content-Length": {"2048"}}, true},
{"equal", http.Header{"Content-Length": {"1024"}}, false},
{"nil", nil, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherQueryCompare(t *testing.T) {
// 3-param form: Query(key, op, value)
tests := []struct {
desc string
rule string
query url.Values
want bool
}{
{"gt match", `Query("page", "gt", "2")`, url.Values{"page": {"3"}}, true},
{"gt equal", `Query("page", "gt", "2")`, url.Values{"page": {"2"}}, false},
{"ge match", `Query("page", "ge", "2")`, url.Values{"page": {"2"}}, true},
{"ge less", `Query("page", "ge", "2")`, url.Values{"page": {"1"}}, false},
{"lt match", `Query("page", "lt", "5")`, url.Values{"page": {"3"}}, true},
{"lt equal", `Query("page", "lt", "5")`, url.Values{"page": {"5"}}, false},
{"le match", `Query("page", "le", "5")`, url.Values{"page": {"5"}}, true},
{"le greater", `Query("page", "le", "5")`, url.Values{"page": {"6"}}, false},
{"eq match", `Query("page", "eq", "5")`, url.Values{"page": {"5"}}, true},
{"eq no match", `Query("page", "eq", "5")`, url.Values{"page": {"6"}}, false},
{"ne match", `Query("page", "ne", "5")`, url.Values{"page": {"6"}}, true},
{"ne no match", `Query("page", "ne", "5")`, url.Values{"page": {"5"}}, false},
{"missing key", `Query("nonexistent", "gt", "0")`, url.Values{"page": {"1"}}, false},
{"non-numeric", `Query("page", "gt", "0")`, url.Values{"page": {"abc"}}, false},
{"nil query", `Query("page", "gt", "0")`, nil, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
m, err := NewMatcher(tc.rule)
require.NoError(t, err)
req := &routing.Request{Query: tc.query}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherBodyJSONCompare(t *testing.T) {
// 3-param form: BodyJSON(path, op, value)
tests := []struct {
desc string
rule string
body string
want bool
}{
{"gt match", `BodyJSON("age", "gt", "18")`, `{"age":21}`, true},
{"gt equal", `BodyJSON("age", "gt", "18")`, `{"age":18}`, false},
{"ge match", `BodyJSON("age", "ge", "18")`, `{"age":18}`, true},
{"ge less", `BodyJSON("age", "ge", "18")`, `{"age":17}`, false},
{"eq match", `BodyJSON("age", "eq", "18")`, `{"age":18}`, true},
{"eq no match", `BodyJSON("age", "eq", "18")`, `{"age":17}`, false},
{"ne match", `BodyJSON("age", "ne", "18")`, `{"age":17}`, true},
{"ne no match", `BodyJSON("age", "ne", "18")`, `{"age":18}`, false},
{"lt match", `BodyJSON("price", "lt", "100")`, `{"price":50}`, true},
{"lt equal", `BodyJSON("price", "lt", "100")`, `{"price":100}`, false},
{"le match", `BodyJSON("price", "le", "100")`, `{"price":100}`, true},
{"le greater", `BodyJSON("price", "le", "100")`, `{"price":101}`, false},
{"nested path", `BodyJSON("user.age", "gt", "18")`, `{"user":{"age":25}}`, true},
{"empty body", `BodyJSON("age", "gt", "0")`, ``, false},
{"missing field", `BodyJSON("nonexistent", "gt", "0")`, `{"age":21}`, false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
m, err := NewMatcher(tc.rule)
require.NoError(t, err)
req := &routing.Request{Body: []byte(tc.body)}
assert.Equal(t, tc.want, m.Match(req))
})
}
}
func TestMatcherCompareInvalidOp(t *testing.T) {
// Invalid operator in 3-param form → error
tests := []string{
`Header("X", "==", "100")`,
`Query("x", "==", "100")`,
`BodyJSON("x", "==", "100")`,
}
for _, rule := range tests {
t.Run(rule, func(t *testing.T) {
_, err := NewMatcher(rule)
assert.Error(t, err)
})
}
}
func TestMatcherCompareInvalidValue(t *testing.T) {
// Non-numeric compare value in 3-param form → error
tests := []string{
`Header("Content-Length", "gt", "not-a-number")`,
`Query("page", "gt", "not-a-number")`,
`BodyJSON("age", "gt", "not-a-number")`,
}
for _, rule := range tests {
t.Run(rule, func(t *testing.T) {
_, err := NewMatcher(rule)
assert.Error(t, err)
})
}
}
func TestMatcherCompareWithBoolean(t *testing.T) {
// 3-param Header combined with Path via &&
m, err := NewMatcher(`Header("Content-Length", "gt", "100") && Path("/upload")`)
require.NoError(t, err)
tests := []struct {
desc string
header http.Header
path string
want bool
}{
{"both match", http.Header{"Content-Length": {"200"}}, "/upload", true},
{"header no match", http.Header{"Content-Length": {"50"}}, "/upload", false},
{"path no match", http.Header{"Content-Length": {"200"}}, "/download", false},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
req := &routing.Request{Header: tc.header, Path: tc.path}
assert.Equal(t, tc.want, m.Match(req))
})
}
}