From a478bc62773173d3711a4f1e2121e35b2f6b6986 Mon Sep 17 00:00:00 2001 From: Marcus Cobden <52370+leth@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:47:17 +0000 Subject: [PATCH] feat: Add custom regex_match string comparison --- core/pkg/evaluator/json.go | 1 + core/pkg/evaluator/string_comparison.go | 135 +++++++++++++ core/pkg/evaluator/string_comparison_test.go | 188 ++++++++++++++++++ .../flag-configuration.md | 1 + docs/reference/flag-definitions.md | 13 +- 5 files changed, 332 insertions(+), 6 deletions(-) diff --git a/core/pkg/evaluator/json.go b/core/pkg/evaluator/json.go index 5849bb5f3..fa08bfbfc 100644 --- a/core/pkg/evaluator/json.go +++ b/core/pkg/evaluator/json.go @@ -153,6 +153,7 @@ func NewResolver(store store.IStore, logger *logger.Logger, jsonEvalTracer trace jsonlogic.AddOperator(FractionEvaluationName, NewFractional(logger).Evaluate) jsonlogic.AddOperator(StartsWithEvaluationName, NewStringComparisonEvaluator(logger).StartsWithEvaluation) jsonlogic.AddOperator(EndsWithEvaluationName, NewStringComparisonEvaluator(logger).EndsWithEvaluation) + jsonlogic.AddOperator(RegexMatchEvaluationName, NewRegexMatchEvaluator(logger, store).RegexMatchEvaluation) jsonlogic.AddOperator(SemVerEvaluationName, NewSemVerComparison(logger).SemVerEvaluation) return Resolver{store: store, Logger: logger, tracer: jsonEvalTracer} diff --git a/core/pkg/evaluator/string_comparison.go b/core/pkg/evaluator/string_comparison.go index d4a0d6b7c..1d8e3e6e0 100644 --- a/core/pkg/evaluator/string_comparison.go +++ b/core/pkg/evaluator/string_comparison.go @@ -1,16 +1,21 @@ package evaluator import ( + "context" "errors" "fmt" + "regexp" "strings" "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/store" + "go.uber.org/zap" ) const ( StartsWithEvaluationName = "starts_with" EndsWithEvaluationName = "ends_with" + RegexMatchEvaluationName = "regex_match" ) type StringComparisonEvaluator struct { @@ -123,3 +128,133 @@ func parseStringComparisonEvaluationData(values interface{}) (string, string, er return property, targetValue, nil } + +type RegexMatchEvaluator struct { + Logger *logger.Logger + // RegexCache caches compiled regex patterns for reuse + RegexCache *map[string]*regexp.Regexp + // PrevRegexCache holds the previous cache to allow for cache retention across config reloads + PrevRegexCache *map[string]*regexp.Regexp +} + +func NewRegexMatchEvaluator(log *logger.Logger, s store.IStore) *RegexMatchEvaluator { + self := &RegexMatchEvaluator{ + Logger: log, + RegexCache: &map[string]*regexp.Regexp{}, + PrevRegexCache: &map[string]*regexp.Regexp{}, + } + + watcher := make(chan store.FlagQueryResult, 1) + go func() { + for range watcher { + // On config change, rotate the regex caches + // If the current cache is empty, do nothing, to keep the previous cache intact in case it is still helpful + if len(*self.RegexCache) == 0 { + continue + } + self.PrevRegexCache = self.RegexCache + self.RegexCache = &map[string]*regexp.Regexp{} + } + }() + selector := store.NewSelector("") + s.Watch(context.Background(), &selector, watcher) + + return self +} + +// RegexMatchEvaluation checks if the given property matches a certain regex pattern. +// It returns 'true', if the value of the given property matches the pattern, 'false' if not. +// As an example, it can be used in the following way inside an 'if' evaluation: +// +// { +// "if": [ +// { +// "regex_match": [{"var": "email"}, ".*@faas\\.com"] +// }, +// "red", null +// ] +// } +// +// This rule can be applied to the following data object, where the evaluation will resolve to 'true': +// +// { "email": "user@faas.com" } +// +// Note that the 'regex_match' evaluation rule must contain two or three items, all of which resolve to a +// string value. +// The first item is the property to check, the second item is the regex pattern to match against, +// and an optional third item can contain regex flags (e.g. "i" for case-insensitive matching). +func (rme *RegexMatchEvaluator) RegexMatchEvaluation(values, _ interface{}) interface{} { + propertyValue, pattern, flags, err := parseRegexMatchEvaluationData(values) + if err != nil { + rme.Logger.Error("error parsing regex_match evaluation data: %v", zap.Error(err)) + return false + } + + re, err := rme.getRegex(pattern, flags) + if err != nil { + rme.Logger.Error("error compiling regex pattern: %v", zap.Error(err)) + return false + } + + return re.MatchString(propertyValue) +} + +var validFlagsStringRe *regexp.Regexp = regexp.MustCompile("[imsU]+") + +func parseRegexMatchEvaluationData(values interface{}) (string, string, string, error) { + parsed, ok := values.([]interface{}) + if !ok { + return "", "", "", errors.New("regex_match evaluation is not an array") + } + + if len(parsed) != 2 && len(parsed) != 3 { + return "", "", "", errors.New("regex_match evaluation must contain a value, a regex pattern, and (optionally) regex flags") + } + + property, ok := parsed[0].(string) + if !ok { + return "", "", "", errors.New("regex_match evaluation: property did not resolve to a string value") + } + + pattern, ok := parsed[1].(string) + if !ok { + return "", "", "", errors.New("regex_match evaluation: pattern did not resolve to a string value") + } + + flags := "" + if (len(parsed) == 3) { + flags, ok = parsed[2].(string) + if !ok { + return "", "", "", errors.New("regex_match evaluation: flags did not resolve to a string value") + } + if !validFlagsStringRe.MatchString(flags) { + return "", "", "", errors.New("regex_match evaluation: flags value is invalid") + } + } + + return property, pattern, flags, nil +} + +func (rme *RegexMatchEvaluator) getRegex(pattern string, flags string) (*regexp.Regexp, error) { + finalPattern := pattern + if flags != "" { + finalPattern = fmt.Sprintf("(?%s)%s", flags, pattern) + } + + if cached, exists := (*rme.RegexCache)[finalPattern]; exists { + return cached, nil + } + + // Check previous cache to allow for cache retention across config reloads + if cached, exists := (*rme.PrevRegexCache)[finalPattern]; exists { + (*rme.RegexCache)[finalPattern] = cached + delete(*rme.PrevRegexCache, finalPattern) + return cached, nil + } + + regexp, err := regexp.Compile(finalPattern) + if err != nil { + return nil, err + } + return regexp, nil +} \ No newline at end of file diff --git a/core/pkg/evaluator/string_comparison_test.go b/core/pkg/evaluator/string_comparison_test.go index f22466f02..9fd939bca 100644 --- a/core/pkg/evaluator/string_comparison_test.go +++ b/core/pkg/evaluator/string_comparison_test.go @@ -431,3 +431,191 @@ func Test_parseStringComparisonEvaluationData(t *testing.T) { }) } } + +func TestJSONEvaluator_regexMatchEvaluation(t *testing.T) { + const source = "testSource" + var sources = []string{source} + ctx := context.Background() + + tests := map[string]struct { + flags []model.Flag + flagKey string + context map[string]any + expectedValue string + expectedVariant string + expectedReason string + expectedError error + }{ + "two strings provided - match": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": ["user@faas.com", ".*"] + }, + "red", null + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "resolve target property using nested operation - match": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": [{"var": "email"}, ".*@.*"] + }, + "red", null + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "user@faas.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "two strings provided - no match": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": ["user@faas.com", ".*FAAS.*"] + }, + "red", "green" + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "user@faas.com", + }, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "three strings provided - match": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": ["user@faas.com", ".*FAAS.*", "i"] + }, + "red", null + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "resolve target property using nested operation - no match": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": [{"var": "email"}, "nope"] + }, + "red", "green" + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "user@faas.com", + }, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing - return default": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: "red", + Variants: colorVariants, + Targeting: []byte(`{ + "if": [ + { + "regex_match": "no-array" + }, + "red", "green" + ] + }`), + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "user@faas.com", + }, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + } + + const reqID = "default" + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + log := logger.NewLogger(nil, false) + s, err := store.NewStore(log, sources) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + je := NewJSON(log, s) + je.store.Update(source, tt.flags, model.Metadata{}) + + value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) + + if value != tt.expectedValue { + t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) + } + + if variant != tt.expectedVariant { + t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) + } + + if reason != tt.expectedReason { + t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) + } + + if err != tt.expectedError { + t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) + } + }) + } +} \ No newline at end of file diff --git a/docs/architecture-decisions/flag-configuration.md b/docs/architecture-decisions/flag-configuration.md index 3661ad5a8..e2c3b3956 100644 --- a/docs/architecture-decisions/flag-configuration.md +++ b/docs/architecture-decisions/flag-configuration.md @@ -52,6 +52,7 @@ The system provides two tiers of operators: - `fractional`: Deterministic percentage-based distribution using murmur3 hashing - `starts_with`/`ends_with`: String prefix/suffix matching for common patterns +- `regex_match`: String regular expression matching - `sem_ver`: Semantic version comparisons with standard (npm-style) operators - `$ref`: Reference to shared evaluators for DRY principle diff --git a/docs/reference/flag-definitions.md b/docs/reference/flag-definitions.md index 59974c9d8..bee0de4cf 100644 --- a/docs/reference/flag-definitions.md +++ b/docs/reference/flag-definitions.md @@ -245,12 +245,13 @@ These are custom operations specific to flagd and flagd providers. They are purpose-built extensions to JsonLogic in order to support common feature flag use cases. Consistent with built-in JsonLogic operators, flagd's custom operators return falsy/nullish values with invalid inputs. -| Function | Description | Context attribute type | Example | -| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }`
Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.

Additional documentation can be found [here](./custom-operations/fractional-operation.md). | -| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`
Result: `true`

Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). | -| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "noreply@example.com", "@example.com"] }`
Result: `true`

Logic: `#!json { ends_with" : [ "noreply@example.com", "@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). | -| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`
Result: `true`

Additional documentation can be found [here](./custom-operations/semver-operation.md). | +| Function | Description | Context attribute type | Example | +| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }`
Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.

Additional documentation can be found [here](./custom-operations/fractional-operation.md). | +| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`
Result: `true`

Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). | +| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "noreply@example.com", "@example.com"] }`
Result: `true`

Logic: `#!json { ends_with" : [ "noreply@example.com", "@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). | +| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`
Result: `true`

Additional documentation can be found [here](./custom-operations/semver-operation.md). | +| `regex_match` (_available TBD_) | Attribute matches the specified regular expression | string | Logic: `#!json { "regex_match" : [ "noreply@example.com", ".*@example.com"] }`
Result: `true`

Logic: `#!json { regex_match" : [ "noreply@example.com", ".*@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). | #### Targeting key