Skip to content
Merged
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
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
86 changes: 70 additions & 16 deletions src/config/config_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -194,32 +242,38 @@ 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(
"loading descriptor: key=%s%s", newParentKey, rateLimitDebugString)
newDescriptor := &rateLimitDescriptor{
descriptors: map[string]*rateLimitDescriptor{},
limit: rateLimit,
wildcardKeys: nil,
wildcardEntries: nil,
valueToMetric: descriptorConfig.ValueToMetric,
shareThreshold: descriptorConfig.ShareThreshold,
wildcardPattern: wildcardPattern,
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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
}
}
Expand Down
Loading
Loading