Skip to content

Commit f34fdd2

Browse files
committed
feature: Add custom regex_match string comparison
1 parent efda06a commit f34fdd2

File tree

5 files changed

+332
-6
lines changed

5 files changed

+332
-6
lines changed

core/pkg/evaluator/json.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ func NewResolver(store store.IStore, logger *logger.Logger, jsonEvalTracer trace
153153
jsonlogic.AddOperator(FractionEvaluationName, NewFractional(logger).Evaluate)
154154
jsonlogic.AddOperator(StartsWithEvaluationName, NewStringComparisonEvaluator(logger).StartsWithEvaluation)
155155
jsonlogic.AddOperator(EndsWithEvaluationName, NewStringComparisonEvaluator(logger).EndsWithEvaluation)
156+
jsonlogic.AddOperator(RegexMatchEvaluationName, NewRegexMatchEvaluator(logger, store).RegexMatchEvaluation)
156157
jsonlogic.AddOperator(SemVerEvaluationName, NewSemVerComparison(logger).SemVerEvaluation)
157158

158159
return Resolver{store: store, Logger: logger, tracer: jsonEvalTracer}

core/pkg/evaluator/string_comparison.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package evaluator
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
7+
"regexp"
68
"strings"
79

810
"github.com/open-feature/flagd/core/pkg/logger"
11+
"github.com/open-feature/flagd/core/pkg/store"
12+
"go.uber.org/zap"
913
)
1014

1115
const (
1216
StartsWithEvaluationName = "starts_with"
1317
EndsWithEvaluationName = "ends_with"
18+
RegexMatchEvaluationName = "regex_match"
1419
)
1520

1621
type StringComparisonEvaluator struct {
@@ -123,3 +128,133 @@ func parseStringComparisonEvaluationData(values interface{}) (string, string, er
123128

124129
return property, targetValue, nil
125130
}
131+
132+
type RegexMatchEvaluator struct {
133+
Logger *logger.Logger
134+
// RegexCache caches compiled regex patterns for reuse
135+
RegexCache *map[string]*regexp.Regexp
136+
// PrevRegexCache holds the previous cache to allow for cache retention across config reloads
137+
PrevRegexCache *map[string]*regexp.Regexp
138+
}
139+
140+
func NewRegexMatchEvaluator(log *logger.Logger, s store.IStore) *RegexMatchEvaluator {
141+
self := &RegexMatchEvaluator{
142+
Logger: log,
143+
RegexCache: &map[string]*regexp.Regexp{},
144+
PrevRegexCache: &map[string]*regexp.Regexp{},
145+
}
146+
147+
watcher := make(chan store.FlagQueryResult, 1)
148+
go func() {
149+
for range watcher {
150+
// On config change, rotate the regex caches
151+
// If the current cache is empty, do nothing, to keep the previous cache intact in case it is still helpful
152+
if len(*self.RegexCache) == 0 {
153+
continue
154+
}
155+
self.PrevRegexCache = self.RegexCache
156+
self.RegexCache = &map[string]*regexp.Regexp{}
157+
}
158+
}()
159+
selector := store.NewSelector("")
160+
s.Watch(context.Background(), &selector, watcher)
161+
162+
return self
163+
}
164+
165+
// RegexMatchEvaluation checks if the given property matches a certain regex pattern.
166+
// It returns 'true', if the value of the given property matches the pattern, 'false' if not.
167+
// As an example, it can be used in the following way inside an 'if' evaluation:
168+
//
169+
// {
170+
// "if": [
171+
// {
172+
// "regex_match": [{"var": "email"}, ".*@faas\\.com"]
173+
// },
174+
// "red", null
175+
// ]
176+
// }
177+
//
178+
// This rule can be applied to the following data object, where the evaluation will resolve to 'true':
179+
//
180+
// { "email": "[email protected]" }
181+
//
182+
// Note that the 'regex_match' evaluation rule must contain two or three items, all of which resolve to a
183+
// string value.
184+
// The first item is the property to check, the second item is the regex pattern to match against,
185+
// and an optional third item can contain regex flags (e.g. "i" for case-insensitive matching).
186+
func (rme *RegexMatchEvaluator) RegexMatchEvaluation(values, _ interface{}) interface{} {
187+
propertyValue, pattern, flags, err := parseRegexMatchEvaluationData(values)
188+
if err != nil {
189+
rme.Logger.Error("error parsing regex_match evaluation data: %v", zap.Error(err))
190+
return false
191+
}
192+
193+
re, err := rme.getRegex(pattern, flags)
194+
if err != nil {
195+
rme.Logger.Error("error compiling regex pattern: %v", zap.Error(err))
196+
return false
197+
}
198+
199+
return re.MatchString(propertyValue)
200+
}
201+
202+
var validFlagsStringRe *regexp.Regexp = regexp.MustCompile("[imsU]+")
203+
204+
func parseRegexMatchEvaluationData(values interface{}) (string, string, string, error) {
205+
parsed, ok := values.([]interface{})
206+
if !ok {
207+
return "", "", "", errors.New("regex_match evaluation is not an array")
208+
}
209+
210+
if len(parsed) != 2 && len(parsed) != 3 {
211+
return "", "", "", errors.New("regex_match evaluation must contain a value, a regex pattern, and (optionally) regex flags")
212+
}
213+
214+
property, ok := parsed[0].(string)
215+
if !ok {
216+
return "", "", "", errors.New("regex_match evaluation: property did not resolve to a string value")
217+
}
218+
219+
pattern, ok := parsed[1].(string)
220+
if !ok {
221+
return "", "", "", errors.New("regex_match evaluation: pattern did not resolve to a string value")
222+
}
223+
224+
flags := ""
225+
if (len(parsed) == 3) {
226+
flags, ok = parsed[2].(string)
227+
if !ok {
228+
return "", "", "", errors.New("regex_match evaluation: flags did not resolve to a string value")
229+
}
230+
if !validFlagsStringRe.MatchString(flags) {
231+
return "", "", "", errors.New("regex_match evaluation: flags value is invalid")
232+
}
233+
}
234+
235+
return property, pattern, flags, nil
236+
}
237+
238+
func (rme *RegexMatchEvaluator) getRegex(pattern string, flags string) (*regexp.Regexp, error) {
239+
finalPattern := pattern
240+
if flags != "" {
241+
finalPattern = fmt.Sprintf("(?%s)%s", flags, pattern)
242+
}
243+
244+
if cached, exists := (*rme.RegexCache)[finalPattern]; exists {
245+
return cached, nil
246+
}
247+
248+
// Check previous cache to allow for cache retention across config reloads
249+
if cached, exists := (*rme.PrevRegexCache)[finalPattern]; exists {
250+
(*rme.RegexCache)[finalPattern] = cached
251+
delete(*rme.PrevRegexCache, finalPattern)
252+
return cached, nil
253+
}
254+
255+
regexp, err := regexp.Compile(finalPattern)
256+
if err != nil {
257+
return nil, err
258+
}
259+
return regexp, nil
260+
}

core/pkg/evaluator/string_comparison_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,191 @@ func Test_parseStringComparisonEvaluationData(t *testing.T) {
431431
})
432432
}
433433
}
434+
435+
func TestJSONEvaluator_regexMatchEvaluation(t *testing.T) {
436+
const source = "testSource"
437+
var sources = []string{source}
438+
ctx := context.Background()
439+
440+
tests := map[string]struct {
441+
flags []model.Flag
442+
flagKey string
443+
context map[string]any
444+
expectedValue string
445+
expectedVariant string
446+
expectedReason string
447+
expectedError error
448+
}{
449+
"two strings provided - match": {
450+
flags: []model.Flag{{
451+
Key: "headerColor",
452+
State: "ENABLED",
453+
DefaultVariant: "red",
454+
Variants: colorVariants,
455+
Targeting: []byte(`{
456+
"if": [
457+
{
458+
"regex_match": ["[email protected]", ".*"]
459+
},
460+
"red", null
461+
]
462+
}`),
463+
},
464+
},
465+
flagKey: "headerColor",
466+
context: map[string]any{},
467+
expectedVariant: "red",
468+
expectedValue: "#FF0000",
469+
expectedReason: model.TargetingMatchReason,
470+
},
471+
"resolve target property using nested operation - match": {
472+
flags: []model.Flag{{
473+
Key: "headerColor",
474+
State: "ENABLED",
475+
DefaultVariant: "red",
476+
Variants: colorVariants,
477+
Targeting: []byte(`{
478+
"if": [
479+
{
480+
"regex_match": [{"var": "email"}, ".*@.*"]
481+
},
482+
"red", null
483+
]
484+
}`),
485+
},
486+
},
487+
flagKey: "headerColor",
488+
context: map[string]any{
489+
"email": "[email protected]",
490+
},
491+
expectedVariant: "red",
492+
expectedValue: "#FF0000",
493+
expectedReason: model.TargetingMatchReason,
494+
},
495+
"two strings provided - no match": {
496+
flags: []model.Flag{{
497+
Key: "headerColor",
498+
State: "ENABLED",
499+
DefaultVariant: "red",
500+
Variants: colorVariants,
501+
Targeting: []byte(`{
502+
"if": [
503+
{
504+
"regex_match": ["[email protected]", ".*FAAS.*"]
505+
},
506+
"red", "green"
507+
]
508+
}`),
509+
},
510+
},
511+
flagKey: "headerColor",
512+
context: map[string]any{
513+
"email": "[email protected]",
514+
},
515+
expectedVariant: "green",
516+
expectedValue: "#00FF00",
517+
expectedReason: model.TargetingMatchReason,
518+
},
519+
"three strings provided - match": {
520+
flags: []model.Flag{{
521+
Key: "headerColor",
522+
State: "ENABLED",
523+
DefaultVariant: "red",
524+
Variants: colorVariants,
525+
Targeting: []byte(`{
526+
"if": [
527+
{
528+
"regex_match": ["[email protected]", ".*FAAS.*", "i"]
529+
},
530+
"red", null
531+
]
532+
}`),
533+
},
534+
},
535+
flagKey: "headerColor",
536+
context: map[string]any{},
537+
expectedVariant: "red",
538+
expectedValue: "#FF0000",
539+
expectedReason: model.TargetingMatchReason,
540+
},
541+
"resolve target property using nested operation - no match": {
542+
flags: []model.Flag{{
543+
Key: "headerColor",
544+
State: "ENABLED",
545+
DefaultVariant: "red",
546+
Variants: colorVariants,
547+
Targeting: []byte(`{
548+
"if": [
549+
{
550+
"regex_match": [{"var": "email"}, "nope"]
551+
},
552+
"red", "green"
553+
]
554+
}`),
555+
},
556+
},
557+
flagKey: "headerColor",
558+
context: map[string]any{
559+
"email": "[email protected]",
560+
},
561+
expectedVariant: "green",
562+
expectedValue: "#00FF00",
563+
expectedReason: model.TargetingMatchReason,
564+
},
565+
"error during parsing - return default": {
566+
flags: []model.Flag{{
567+
Key: "headerColor",
568+
State: "ENABLED",
569+
DefaultVariant: "red",
570+
Variants: colorVariants,
571+
Targeting: []byte(`{
572+
"if": [
573+
{
574+
"regex_match": "no-array"
575+
},
576+
"red", "green"
577+
]
578+
}`),
579+
},
580+
},
581+
flagKey: "headerColor",
582+
context: map[string]any{
583+
"email": "[email protected]",
584+
},
585+
expectedVariant: "green",
586+
expectedValue: "#00FF00",
587+
expectedReason: model.TargetingMatchReason,
588+
},
589+
}
590+
591+
const reqID = "default"
592+
for name, tt := range tests {
593+
t.Run(name, func(t *testing.T) {
594+
log := logger.NewLogger(nil, false)
595+
s, err := store.NewStore(log, sources)
596+
if err != nil {
597+
t.Fatalf("NewStore failed: %v", err)
598+
}
599+
je := NewJSON(log, s)
600+
je.store.Update(source, tt.flags, model.Metadata{})
601+
602+
value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
603+
604+
if value != tt.expectedValue {
605+
t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
606+
}
607+
608+
if variant != tt.expectedVariant {
609+
t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant)
610+
}
611+
612+
if reason != tt.expectedReason {
613+
t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason)
614+
}
615+
616+
if err != tt.expectedError {
617+
t.Errorf("expected err '%v', got '%v'", tt.expectedError, err)
618+
}
619+
})
620+
}
621+
}

docs/architecture-decisions/flag-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The system provides two tiers of operators:
5252

5353
- `fractional`: Deterministic percentage-based distribution using murmur3 hashing
5454
- `starts_with`/`ends_with`: String prefix/suffix matching for common patterns
55+
- `regex_match`: String regular expression matching
5556
- `sem_ver`: Semantic version comparisons with standard (npm-style) operators
5657
- `$ref`: Reference to shared evaluators for DRY principle
5758

docs/reference/flag-definitions.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,13 @@ These are custom operations specific to flagd and flagd providers.
245245
They are purpose-built extensions to JsonLogic in order to support common feature flag use cases.
246246
Consistent with built-in JsonLogic operators, flagd's custom operators return falsy/nullish values with invalid inputs.
247247

248-
| Function | Description | Context attribute type | Example |
249-
| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
250-
| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }` <br>Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.<br><br>Additional documentation can be found [here](./custom-operations/fractional-operation.md). |
251-
| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`<br>Result: `true`<br><br>Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`<br>Result: `false`<br>Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
252-
| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "[email protected]", "@example.com"] }`<br>Result: `true`<br><br>Logic: `#!json { ends_with" : [ "[email protected]", "@test.com"] }`<br>Result: `false`<br>Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
253-
| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`<br>Result: `true`<br><br>Additional documentation can be found [here](./custom-operations/semver-operation.md). |
248+
| Function | Description | Context attribute type | Example |
249+
| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
250+
| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }` <br>Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.<br><br>Additional documentation can be found [here](./custom-operations/fractional-operation.md). |
251+
| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`<br>Result: `true`<br><br>Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`<br>Result: `false`<br>Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
252+
| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "[email protected]", "@example.com"] }`<br>Result: `true`<br><br>Logic: `#!json { ends_with" : [ "[email protected]", "@test.com"] }`<br>Result: `false`<br>Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
253+
| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`<br>Result: `true`<br><br>Additional documentation can be found [here](./custom-operations/semver-operation.md). |
254+
| `regex_match` (_available TBD_) | Attribute matches the specified regular expression | string | Logic: `#!json { "regex_match" : [ "[email protected]", ".*@example.com"] }`<br>Result: `true`<br><br>Logic: `#!json { regex_match" : [ "[email protected]", ".*@test.com"] }`<br>Result: `false`<br>Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
254255

255256
#### Targeting key
256257

0 commit comments

Comments
 (0)