From 2386daf8f894c03afd2ce3b8bed94dac5a39241a Mon Sep 17 00:00:00 2001 From: Nam Dang Date: Thu, 12 Mar 2026 00:54:21 +0700 Subject: [PATCH] feat: Support wildcard in non-trailing positions for rate limit descriptor values Signed-off-by: Nam Dang --- README.md | 37 ++++- src/config/config_impl.go | 86 ++++++++--- test/config/config_test.go | 239 ++++++++++++++++++++++++++++++- test/config/share_threshold.yaml | 33 +++++ test/config/wildcard.yaml | 5 + 5 files changed, 374 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f2d9291d3..538b413c1 100644 --- a/README.md +++ b/README.md @@ -643,12 +643,9 @@ rather than the normal #### Example 9 -Value supports wildcard matching to apply rate-limit for nested endpoints: +Value supports wildcard matching using `*`, which can appear at any position — trailing, middle, or multiple times. Each `*` matches zero or more characters. -``` -(key_1, value_1): 20 / sec -(key_1, value_2): 20 / sec -``` +Trailing wildcard — matches any value starting with the given prefix: ```yaml domain: example9 @@ -660,6 +657,36 @@ descriptors: requests_per_unit: 20 ``` +Matches `value1`, `value2`, `valueXYZ`, etc. + +Middle wildcard — matches values with a fixed prefix **and** suffix: + +```yaml +domain: example9 +descriptors: + - key: path + value: /api/*/action + rate_limit: + unit: minute + requests_per_unit: 20 +``` + +Matches `/api/123/action`, `/api/user-id/action`. Does not match `/api/123/other`. + +Multiple wildcards — each `*` matches an independent segment, in order: + +```yaml +domain: example9 +descriptors: + - key: route + value: /api/*/resource/*/action + rate_limit: + unit: minute + requests_per_unit: 20 +``` + +Matches `/api/v1/resource/123/action`, `/api/v2/resource/456/action`. + #### Example 10 Using `value_to_metric: true` to include descriptor values in metrics when values are not explicitly defined in the configuration: diff --git a/src/config/config_impl.go b/src/config/config_impl.go index 98aa06eb9..dd3c5b5e8 100644 --- a/src/config/config_impl.go +++ b/src/config/config_impl.go @@ -42,10 +42,17 @@ type YamlRoot struct { Descriptors []YamlDescriptor } +// wildcardMatchEntry holds a pre-computed wildcard pattern for non-trailing * matching. +// parts is the pattern split on "*" at load time, avoiding allocations per request. +type wildcardMatchEntry struct { + key string // full descriptor key, e.g. "path_bar*baz*qux" + parts []string // pre-split on "*", e.g. ["path_bar", "baz", "qux"] +} + type rateLimitDescriptor struct { descriptors map[string]*rateLimitDescriptor limit *RateLimit - wildcardKeys []string + wildcardEntries []wildcardMatchEntry // all wildcard patterns, pre-split at load time valueToMetric bool shareThreshold bool wildcardPattern string // stores the wildcard pattern when share_threshold is true @@ -127,6 +134,47 @@ func newRateLimitConfigError(name string, err string) RateLimitConfigError { return RateLimitConfigError(fmt.Sprintf("%s: %s", name, err)) } +// wildcardMatch reports whether value matches a pre-split wildcard pattern. +// parts is the pattern split on "*" at load time (e.g. ["path_bar", "baz", "qux"]). +// Each * matches zero or more characters. No allocations are performed per call. +func wildcardMatch(parts []string, value string) bool { + if len(parts) == 1 { + return parts[0] == value + } + + // Value must start with the first literal segment and end with the last. + if !strings.HasPrefix(value, parts[0]) { + return false + } + if !strings.HasSuffix(value, parts[len(parts)-1]) { + return false + } + + // Ensure total fixed characters don't exceed the value length. + totalFixed := 0 + for _, p := range parts { + totalFixed += len(p) + } + if len(value) < totalFixed { + return false + } + + // Scan middle segments in order within the region between the consumed prefix and suffix. + remaining := value[len(parts[0]):] + if last := parts[len(parts)-1]; last != "" { + remaining = remaining[:len(remaining)-len(last)] + } + for _, part := range parts[1 : len(parts)-1] { + idx := strings.Index(remaining, part) + if idx < 0 { + return false + } + remaining = remaining[idx+len(part):] + } + + return true +} + // Load a set of config descriptors from the YAML file and check the input. // @param config supplies the config file that owns the descriptor. // @param parentKey supplies the fully resolved key name that owns this config level. @@ -194,24 +242,30 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p } } - // Validate share_threshold can only be used with wildcards + // Validate share_threshold can only be used with wildcards (trailing or middle). if descriptorConfig.ShareThreshold { - if len(finalKey) == 0 || finalKey[len(finalKey)-1:] != "*" { + if !strings.Contains(finalKey, "*") { panic(newRateLimitConfigError( config.Name, - fmt.Sprintf("share_threshold can only be used with wildcard values (ending with '*'), but found key '%s'", finalKey))) + fmt.Sprintf("share_threshold can only be used with wildcard values (containing '*'), but found key '%s'", finalKey))) } } - // Store wildcard pattern if share_threshold is enabled + // Store wildcard pattern if share_threshold is enabled. + // Applies to both trailing-* and middle/multi-* patterns. var wildcardPattern string = "" - if descriptorConfig.ShareThreshold && len(finalKey) > 0 && finalKey[len(finalKey)-1:] == "*" { + if descriptorConfig.ShareThreshold && strings.Contains(finalKey, "*") { wildcardPattern = finalKey } - // Preload keys ending with "*" symbol. - if finalKey[len(finalKey)-1:] == "*" { - this.wildcardKeys = append(this.wildcardKeys, finalKey) + // All wildcard patterns go into one unified list with pre-split parts. + // wildcardMatch handles trailing-* with the same performance as HasPrefix + // because HasSuffix("", "") is O(1) and there are no middle segments to scan. + if strings.Contains(finalKey, "*") { + this.wildcardEntries = append(this.wildcardEntries, wildcardMatchEntry{ + key: finalKey, + parts: strings.Split(finalKey, "*"), + }) } logger.Debugf( @@ -219,7 +273,7 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p newDescriptor := &rateLimitDescriptor{ descriptors: map[string]*rateLimitDescriptor{}, limit: rateLimit, - wildcardKeys: nil, + wildcardEntries: nil, valueToMetric: descriptorConfig.ValueToMetric, shareThreshold: descriptorConfig.ShareThreshold, wildcardPattern: wildcardPattern, @@ -298,7 +352,7 @@ func (this *rateLimitConfigImpl) loadConfig(config RateLimitConfigToLoad) { newDomain := &rateLimitDomain{rateLimitDescriptor{ descriptors: map[string]*rateLimitDescriptor{}, limit: nil, - wildcardKeys: nil, + wildcardEntries: nil, valueToMetric: false, shareThreshold: false, wildcardPattern: "", @@ -374,11 +428,11 @@ func (this *rateLimitConfigImpl) GetLimit( nextDescriptor := descriptorsMap[finalKey] var matchedWildcardKey string - if nextDescriptor == nil && len(prevDescriptor.wildcardKeys) > 0 { - for _, wildcardKey := range prevDescriptor.wildcardKeys { - if strings.HasPrefix(finalKey, strings.TrimSuffix(wildcardKey, "*")) { - nextDescriptor = descriptorsMap[wildcardKey] - matchedWildcardKey = wildcardKey + if nextDescriptor == nil && len(prevDescriptor.wildcardEntries) > 0 { + for _, entry := range prevDescriptor.wildcardEntries { + if wildcardMatch(entry.parts, finalKey) { + nextDescriptor = descriptorsMap[entry.key] + matchedWildcardKey = entry.key break } } diff --git a/test/config/config_test.go b/test/config/config_test.go index c32f84155..4b371fb6b 100644 --- a/test/config/config_test.go +++ b/test/config/config_test.go @@ -638,13 +638,75 @@ func TestWildcardConfig(t *testing.T) { }) assert.Nil(eager) - // Wildcard in the middle of value is not supported. - midWildcard := rlConfig.GetLimit( + // Middle wildcard (single *): bar*b matches values with "bar" prefix and "b" suffix. + midWild1 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ - Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWildcard", Value: "barab"}}, + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "barab"}}, }) - assert.Nil(midWildcard) + midWild2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "bar123b"}}, + }) + assert.NotNil(midWild1) + assert.NotNil(midWild2) + assert.Equal(midWild1, midWild2, "Different values matching the same middle wildcard should share the same rate limit") + assert.Equal("test-domain.midWild_bar*b", midWild1.Stats.Key, "Middle wildcard stats key should use the wildcard pattern") + assert.Equal("test-domain.midWild_bar*b", midWild1.FullKey, "Middle wildcard FullKey should use the wildcard pattern") + + // Middle wildcard: zero-length middle is allowed (* matches empty string). + midWildEmpty := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "barb"}}, + }) + assert.NotNil(midWildEmpty, "* should match empty string so 'barb' matches 'bar*b'") + + // Middle wildcard: does not match values that don't satisfy prefix+suffix. + midWildNoMatch1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "bara"}}, + }) + midWildNoMatch2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "xbarb"}}, + }) + assert.Nil(midWildNoMatch1, "Value without required suffix should not match middle wildcard") + assert.Nil(midWildNoMatch2, "Value without required prefix should not match middle wildcard") + + // Multiple wildcards: foo*bar*baz matches values containing "foo"..."bar"..."baz" in order. + multiWild1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foo123bar456baz"}}, + }) + multiWild2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foobarbaz"}}, + }) + assert.NotNil(multiWild1) + assert.NotNil(multiWild2) + assert.Equal(multiWild1, multiWild2, "Different values matching the same multi-wildcard should share the same rate limit") + assert.Equal("test-domain.multiWild_foo*bar*baz", multiWild1.Stats.Key, "Multi-wildcard stats key should use the wildcard pattern") + assert.Equal("test-domain.multiWild_foo*bar*baz", multiWild1.FullKey, "Multi-wildcard FullKey should use the wildcard pattern") + + // Multiple wildcards: does not match when a required segment is missing or out of order. + multiWildNoMatch1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foo123baz"}}, + }) + multiWildNoMatch2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "bar456baz"}}, + }) + assert.Nil(multiWildNoMatch1, "Value missing middle segment 'bar' should not match") + assert.Nil(multiWildNoMatch2, "Value missing required prefix 'foo' should not match") } func TestDetailedMetric(t *testing.T) { @@ -1804,9 +1866,124 @@ func TestShareThreshold(t *testing.T) { counterTest1 := stats.NewCounter("test-domain.file_test1.total_hits").Value() asrt.GreaterOrEqual(counterTest1, uint64(1), "test1 should increment its own counter") }) + + // Test Case 5: share_threshold with middle wildcard (single *) + t.Run("share_threshold with middle wildcard", func(t *testing.T) { + testValues := []string{"/api/v1", "/api-beta-v1", "/apiXYZv1"} + + var rateLimits []*config.RateLimit + for _, value := range testValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midpath", Value: value}}, + }) + asrt.NotNil(rl, "Should match middle wildcard for value %s", value) + asrt.NotNil(rl.ShareThresholdKeyPattern) + asrt.Equal("/api*v1", rl.ShareThresholdKeyPattern[0], "ShareThresholdKeyPattern should hold the wildcard pattern") + asrt.EqualValues(100, rl.Limit.RequestsPerUnit) + asrt.Equal("test-domain.midpath_/api*v1", rl.Stats.Key, "Stats key should use middle wildcard pattern") + asrt.Equal(rl.Stats.Key, rl.FullKey) + rateLimits = append(rateLimits, rl) + } + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All matching values should share the same stats key") + } + + // Non-matching values should not match + noMatch := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midpath", Value: "/api/v2"}}, + }) + asrt.Nil(noMatch, "Value not satisfying prefix+suffix should not match") + }) + + // Test Case 6: share_threshold with multi-wildcard (multiple *) + t.Run("share_threshold with multi-wildcard", func(t *testing.T) { + testValues := []string{"svcAepBrpc", "svcXXXepYYYrpc", "svceprpc"} + + var rateLimits []*config.RateLimit + for _, value := range testValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiroute", Value: value}}, + }) + asrt.NotNil(rl, "Should match multi-wildcard for value %s", value) + asrt.NotNil(rl.ShareThresholdKeyPattern) + asrt.Equal("svc*ep*rpc", rl.ShareThresholdKeyPattern[0]) + asrt.EqualValues(50, rl.Limit.RequestsPerUnit) + asrt.Equal("test-domain.multiroute_svc*ep*rpc", rl.Stats.Key, "Stats key should use multi-wildcard pattern") + asrt.Equal(rl.Stats.Key, rl.FullKey) + rateLimits = append(rateLimits, rl) + } + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All matching values should share the same stats key") + } + + // Non-matching: missing middle segment + noMatch := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiroute", Value: "svcABCrpc"}}, + }) + asrt.Nil(noMatch, "Value missing required middle segment 'ep' should not match") + }) + + // Test Case 7: share_threshold with middle wildcard — share_threshold takes priority over value_to_metric + t.Run("share_threshold priority over value_to_metric with middle wildcard", func(t *testing.T) { + rl1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midroute", Value: "/api/v2"}}, + }) + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midroute", Value: "/api-beta-v2"}}, + }) + asrt.NotNil(rl1) + asrt.NotNil(rl2) + asrt.Equal("/api*v2", rl1.ShareThresholdKeyPattern[0]) + asrt.Equal("test-domain.midroute_/api*v2", rl1.Stats.Key, "share_threshold should take priority over value_to_metric") + asrt.Equal(rl1.Stats.Key, rl2.Stats.Key, "Both values should share the same stats key") + }) + + // Test Case 8: share_threshold with nested middle wildcards + t.Run("share_threshold with nested middle wildcards", func(t *testing.T) { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "tenant", Value: "t1prod"}, + {Key: "resource", Value: "resAv2"}, + }, + }) + asrt.NotNil(rl) + asrt.Equal("t*prod", rl.ShareThresholdKeyPattern[0]) + asrt.Equal("res*v2", rl.ShareThresholdKeyPattern[1]) + asrt.Equal("test-domain.tenant_t*prod.resource_res*v2", rl.Stats.Key, "Nested middle wildcards with share_threshold should both use wildcard patterns in stats key") + asrt.Equal(rl.Stats.Key, rl.FullKey) + + // Different matching values should share the same stats key + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "tenant", Value: "t99prod"}, + {Key: "resource", Value: "resBBBv2"}, + }, + }) + asrt.NotNil(rl2) + asrt.Equal(rl.Stats.Key, rl2.Stats.Key, "Different matching values should share the same stats key") + }) } -// TestWildcardStatsBehavior verifies the stats key behavior for wildcards under different flag combinations +// TestWildcardStatsBehavior verifies that trailing wildcards (foo*) and middle/multi wildcards (foo*bar, foo*bar*baz) +// produce identical stats key behavior for all flag combinations (no flags, value_to_metric, share_threshold). +// Since all wildcard patterns now go through the same wildcardEntries+wildcardMatch path, each sub-test +// runs the same scenario for both trailing and middle patterns and asserts equal outcomes. func TestWildcardStatsBehavior(t *testing.T) { asrt := assert.New(t) store := stats.NewStore(stats.NewNullSink(), false) @@ -1978,4 +2155,56 @@ func TestWildcardStatsBehavior(t *testing.T) { asrt.Equal("test-domain.wild_foo*.other_bar.limit", rl.Stats.Key, "Should preserve wildcard pattern when value_to_metric is only on other descriptor") asrt.Equal(rl.Stats.Key, rl.FullKey, "FullKey should match Stats.Key") }) + + // The following sub-tests mirror the trailing-* cases above using middle/multi wildcards, + // explicitly asserting that the unified wildcardEntries+wildcardMatch path produces identical + // flag behavior regardless of wildcard position. + + t.Run("middle+trailing wildcard - no flags - preserves wildcard pattern in stats key", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{{ + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + {Key: "wild", Value: "foo*bar*", RateLimit: &config.YamlRateLimit{RequestsPerUnit: 20, Unit: "minute"}}, + }, + }, + }} + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl1 := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, + }) + rl2 := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "fooXbarY"}}, + }) + asrt.NotNil(rl1) + asrt.NotNil(rl2) + asrt.Equal("test-domain.wild_foo*bar*", rl1.Stats.Key, "Middle+trailing wildcard with no flags should preserve wildcard pattern — same as trailing-only") + asrt.Equal(rl1.Stats.Key, rl2.Stats.Key, "Different matching values should share the same stats key") + asrt.Equal(rl1.Stats.Key, rl1.FullKey) + }) + + t.Run("middle+trailing wildcard - value_to_metric - uses runtime value", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{{ + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + {Key: "wild", Value: "foo*bar*", ValueToMetric: true, RateLimit: &config.YamlRateLimit{RequestsPerUnit: 20, Unit: "minute"}}, + }, + }, + }} + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo123bar456", rl.Stats.Key, "Middle+trailing wildcard with value_to_metric should use runtime value — same as trailing-only") + }) } + +// share_threshold parity (middle+trailing wildcards produce the same shared-counter +// behaviour as trailing-only) is covered by TestShareThreshold Cases 5-7. diff --git a/test/config/share_threshold.yaml b/test/config/share_threshold.yaml index c601576aa..6ebce6a33 100644 --- a/test/config/share_threshold.yaml +++ b/test/config/share_threshold.yaml @@ -85,3 +85,36 @@ descriptors: rate_limit: unit: minute requests_per_unit: 100 + # Test case: share_threshold with middle wildcard (single *) + - key: midpath + value: /api*v1 + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 100 + # Test case: share_threshold with multi-wildcard (multiple *) + - key: multiroute + value: svc*ep*rpc + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 50 + # Test case: share_threshold with middle wildcard + value_to_metric (share_threshold takes priority) + - key: midroute + value: /api*v2 + share_threshold: true + value_to_metric: true + rate_limit: + unit: minute + requests_per_unit: 80 + # Test case: share_threshold with middle wildcard nested + - key: tenant + value: t*prod + share_threshold: true + descriptors: + - key: resource + value: res*v2 + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 60 diff --git a/test/config/wildcard.yaml b/test/config/wildcard.yaml index e99bac663..3a1effcf3 100644 --- a/test/config/wildcard.yaml +++ b/test/config/wildcard.yaml @@ -20,6 +20,11 @@ descriptors: rate_limit: unit: minute requests_per_unit: 20 + - key: multiWild + value: foo*bar*baz + rate_limit: + unit: minute + requests_per_unit: 10 - key: nestedWild value: val1 descriptors: