From d4b85d5507e738caa8a451359e7f016d961aa23d Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Wed, 13 May 2026 18:04:13 -0700 Subject: [PATCH] feat: added local Strategy handlers --- local_strategies.go | 328 +++++++++++++++++++++++++++++++++++++++ local_strategies_test.go | 118 ++++++++++++++ remote.go | 5 - resolver.go | 54 +------ 4 files changed, 447 insertions(+), 58 deletions(-) create mode 100644 local_strategies.go create mode 100644 local_strategies_test.go diff --git a/local_strategies.go b/local_strategies.go new file mode 100644 index 0000000..73a79be --- /dev/null +++ b/local_strategies.go @@ -0,0 +1,328 @@ +package client + +import ( + "encoding/json" + "net" + "regexp" + "slices" + "strconv" + "time" +) + +const ( + StrategyValue = "VALUE_VALIDATION" + StrategyNumeric = "NUMERIC_VALIDATION" + StrategyDate = "DATE_VALIDATION" + StrategyTime = "TIME_VALIDATION" + StrategyPayload = "PAYLOAD_VALIDATION" + StrategyNetwork = "NETWORK_VALIDATION" + StrategyRegex = "REGEX_VALIDATION" +) + +const ( + OperationExist = "EXIST" + OperationNotExist = "NOT_EXIST" + OperationEqual = "EQUAL" + OperationNotEqual = "NOT_EQUAL" + OperationGreater = "GREATER" + OperationLower = "LOWER" + OperationBetween = "BETWEEN" + OperationHasOne = "HAS_ONE" + OperationHasAll = "HAS_ALL" +) + +func processLocalStrategy(strategy SnapshotStrategy, input string) bool { + switch strategy.Strategy { + case StrategyValue: + return processValueStrategy(strategy.Operation, strategy.Values, input) + case StrategyNumeric: + return processNumericStrategy(strategy.Operation, strategy.Values, input) + case StrategyDate: + return processDateStrategy(strategy.Operation, strategy.Values, input) + case StrategyTime: + return processTimeStrategy(strategy.Operation, strategy.Values, input) + case StrategyPayload: + return processPayloadStrategy(strategy.Operation, strategy.Values, input) + case StrategyNetwork: + return processNetworkStrategy(strategy.Operation, strategy.Values, input) + case StrategyRegex: + return processRegexStrategy(strategy.Operation, strategy.Values, input) + default: + return false + } +} + +func processValueStrategy(operation string, values []string, input string) bool { + switch operation { + case OperationExist, OperationEqual: + if len(values) == 0 { + return false + } + if operation == OperationEqual { + return input == values[0] + } + return containsString(values, input) + case OperationNotExist, OperationNotEqual: + return !containsString(values, input) + default: + return false + } +} + +func processNumericStrategy(operation string, values []string, input string) bool { + numericInput, err := strconv.ParseFloat(input, 64) + if err != nil { + return false + } + + numericValues := make([]float64, 0, len(values)) + for _, value := range values { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return false + } + numericValues = append(numericValues, parsed) + } + + switch operation { + case OperationExist, OperationEqual: + return containsFloat(numericValues, numericInput) + case OperationNotExist, OperationNotEqual: + return !containsFloat(numericValues, numericInput) + case OperationGreater: + return anyFloat(numericValues, func(candidate float64) bool { return numericInput > candidate }) + case OperationLower: + return anyFloat(numericValues, func(candidate float64) bool { return numericInput < candidate }) + case OperationBetween: + if len(numericValues) < 2 { + return false + } + return numericValues[0] <= numericInput && numericInput <= numericValues[1] + default: + return false + } +} + +func processDateStrategy(operation string, values []string, input string) bool { + dateInput, ok := parseDateValue(input) + if !ok { + return false + } + + dateValues := make([]time.Time, 0, len(values)) + for _, value := range values { + parsed, ok := parseDateValue(value) + if !ok { + return false + } + dateValues = append(dateValues, parsed) + } + + switch operation { + case OperationLower: + return anyDate(dateValues, func(candidate time.Time) bool { return dateInput.Before(candidate) || dateInput.Equal(candidate) }) + case OperationGreater: + return anyDate(dateValues, func(candidate time.Time) bool { return dateInput.After(candidate) || dateInput.Equal(candidate) }) + case OperationBetween: + if len(dateValues) < 2 { + return false + } + return !dateInput.Before(dateValues[0]) && !dateInput.After(dateValues[1]) + default: + return false + } +} + +func processTimeStrategy(operation string, values []string, input string) bool { + timeInput, ok := parseClockValue(input) + if !ok { + return false + } + + timeValues := make([]time.Time, 0, len(values)) + for _, value := range values { + parsed, ok := parseClockValue(value) + if !ok { + return false + } + timeValues = append(timeValues, parsed) + } + + switch operation { + case OperationLower: + return anyDate(timeValues, func(candidate time.Time) bool { return timeInput.Before(candidate) || timeInput.Equal(candidate) }) + case OperationGreater: + return anyDate(timeValues, func(candidate time.Time) bool { return timeInput.After(candidate) || timeInput.Equal(candidate) }) + case OperationBetween: + if len(timeValues) < 2 { + return false + } + return !timeInput.Before(timeValues[0]) && !timeInput.After(timeValues[1]) + default: + return false + } +} + +func processPayloadStrategy(operation string, values []string, input string) bool { + var payload any + if err := json.Unmarshal([]byte(input), &payload); err != nil { + return false + } + + keys := flattenPayloadKeys(payload) + switch operation { + case OperationHasOne: + return anyString(values, func(value string) bool { return slices.Contains(keys, value) }) + case OperationHasAll: + return allString(values, func(value string) bool { return slices.Contains(keys, value) }) + default: + return false + } +} + +func processNetworkStrategy(operation string, values []string, input string) bool { + ip := net.ParseIP(input) + if ip == nil { + return false + } + + switch operation { + case OperationExist: + return networkExists(values, ip) + case OperationNotExist: + return !networkExists(values, ip) + default: + return false + } +} + +func processRegexStrategy(operation string, values []string, input string) bool { + switch operation { + case OperationExist: + return anyString(values, func(pattern string) bool { return regexMatch(pattern, input, false) }) + case OperationNotExist: + return !anyString(values, func(pattern string) bool { return regexMatch(pattern, input, false) }) + case OperationEqual: + return anyString(values, func(pattern string) bool { return regexMatch(pattern, input, true) }) + case OperationNotEqual: + return !anyString(values, func(pattern string) bool { return regexMatch(pattern, input, true) }) + default: + return false + } +} + +func containsString(values []string, target string) bool { + return slices.Contains(values, target) +} + +func containsFloat(values []float64, target float64) bool { + return slices.Contains(values, target) +} + +func anyFloat(values []float64, predicate func(float64) bool) bool { + return slices.ContainsFunc(values, predicate) +} + +func anyDate(values []time.Time, predicate func(time.Time) bool) bool { + return slices.ContainsFunc(values, predicate) +} + +func anyString(values []string, predicate func(string) bool) bool { + return slices.ContainsFunc(values, predicate) +} + +func allString(values []string, predicate func(string) bool) bool { + for _, value := range values { + if !predicate(value) { + return false + } + } + + return true +} + +func flattenPayloadKeys(value any) []string { + keys := make([]string, 0) + seen := make(map[string]struct{}) + flattenPayloadKeysWithPrefix(value, "", seen) + + for key := range seen { + keys = append(keys, key) + } + + return keys +} + +func flattenPayloadKeysWithPrefix(value any, prefix string, seen map[string]struct{}) { + switch typed := value.(type) { + case map[string]any: + for key, nested := range typed { + next := key + if prefix != "" { + next = prefix + "." + key + } + seen[next] = struct{}{} + flattenPayloadKeysWithPrefix(nested, next, seen) + } + case []any: + if prefix != "" { + seen[prefix] = struct{}{} + } + for _, nested := range typed { + flattenPayloadKeysWithPrefix(nested, prefix, seen) + } + default: + if prefix != "" { + seen[prefix] = struct{}{} + } + } +} + +func parseDateValue(value string) (time.Time, bool) { + layouts := []string{time.DateOnly, "2006-01-02T15:04"} + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + + return time.Time{}, false +} + +func parseClockValue(value string) (time.Time, bool) { + if parsed, err := time.Parse("15:04", value); err == nil { + return parsed, true + } + + return time.Time{}, false +} + +func networkExists(values []string, input net.IP) bool { + for _, value := range values { + if _, network, err := net.ParseCIDR(value); err == nil { + if network.Contains(input) { + return true + } + continue + } + + if parsed := net.ParseIP(value); parsed != nil && parsed.Equal(input) { + return true + } + } + + return false +} + +func regexMatch(pattern, input string, fullMatch bool) bool { + if fullMatch { + pattern = "^(?:" + pattern + ")$" + } + + compiled, err := regexp.Compile(pattern) + if err != nil { + return false + } + + return compiled.MatchString(input) +} diff --git a/local_strategies_test.go b/local_strategies_test.go new file mode 100644 index 0000000..f36781f --- /dev/null +++ b/local_strategies_test.go @@ -0,0 +1,118 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLocalStrategyOperations(t *testing.T) { + makeStrategy := func(strategy, operation string, values []string) SnapshotStrategy { + return SnapshotStrategy{ + Strategy: strategy, + Operation: operation, + Values: values, + Activated: true, + } + } + + t.Run("should evaluate value strategy", func(t *testing.T) { + values := []string{"USER_1", "USER_2"} + + assert.True(t, processLocalStrategy(makeStrategy(StrategyValue, "EXIST", values), "USER_1")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "EXIST", values), "USER_123")) + assert.True(t, processLocalStrategy(makeStrategy(StrategyValue, "NOT_EXIST", values), "USER_123")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "NOT_EXIST", values), "USER_1")) + assert.True(t, processLocalStrategy(makeStrategy(StrategyValue, "EQUAL", values), "USER_1")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "EQUAL", values), "USER_2")) + assert.True(t, processLocalStrategy(makeStrategy(StrategyValue, "NOT_EQUAL", values), "USER_123")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "NOT_EQUAL", values), "USER_2")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "EXIST", []string{}), "USER_1")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyValue, "INVALID_OP", values), "USER_1")) + }) + + t.Run("should evaluate numeric strategy", func(t *testing.T) { + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "EXIST", []string{"1", "3"}), "3")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "NOT_EXIST", []string{"1", "3"}), "1")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "NOT_EXIST", []string{"1", "3"}), "2")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "EQUAL", []string{"1"}), "1")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "EQUAL", []string{"1"}), "2")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "NOT_EQUAL", []string{"1"}), "2")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "GREATER", []string{"1"}), "2")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "GREATER", []string{"1"}), "0")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "LOWER", []string{"1"}), "0")) + assert.True(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "BETWEEN", []string{"1", "3"}), "2")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "GREATER", []string{"1", "3"}), "ABC")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "BETWEEN", []string{"1"}), "1")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "BETWEEN", []string{"ABC", "3"}), "2")) + assert.False(t, processLocalStrategy(makeStrategy("NUMERIC_VALIDATION", "INVALID_OP", []string{"1", "3"}), "2")) + }) + + t.Run("should evaluate date strategy", func(t *testing.T) { + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"2019-12-01"}), "2019-11-26")) + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"2019-12-01"}), "2019-12-01")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"2019-12-01"}), "2019-12-02")) + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "GREATER", []string{"2019-12-01"}), "2019-12-02")) + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "GREATER", []string{"2019-12-01"}), "2019-12-01")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "GREATER", []string{"2019-12-01"}), "2019-11-10")) + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "BETWEEN", []string{"2019-12-01", "2019-12-05"}), "2019-12-03")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "BETWEEN", []string{"2019-12-01", "2019-12-05"}), "2019-12-12")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "BETWEEN", []string{"2019-12-01"}), "2019-12-03")) + assert.True(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"2019-12-01T08:30"}), "2019-12-01T07:00")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"2019-12-01"}), "invalid-date")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "LOWER", []string{"invalid-date"}), "2019-12-01")) + assert.False(t, processLocalStrategy(makeStrategy("DATE_VALIDATION", "INVALID_OP", []string{"2019-12-01"}), "2019-12-01")) + }) + + t.Run("should evaluate time strategy", func(t *testing.T) { + assert.True(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "LOWER", []string{"08:00"}), "06:00")) + assert.True(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "LOWER", []string{"08:00"}), "08:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "LOWER", []string{"08:00"}), "10:00")) + assert.True(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "GREATER", []string{"08:00"}), "10:00")) + assert.True(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "GREATER", []string{"08:00"}), "08:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "GREATER", []string{"08:00"}), "06:00")) + assert.True(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "BETWEEN", []string{"08:00", "10:00"}), "09:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "BETWEEN", []string{"08:00", "10:00"}), "07:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "BETWEEN", []string{"08:00"}), "09:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "GREATER", []string{"08:00"}), "invalid-time")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "GREATER", []string{"invalid-time"}), "08:00")) + assert.False(t, processLocalStrategy(makeStrategy("TIME_VALIDATION", "INVALID_OP", []string{"08:00"}), "08:00")) + }) + + t.Run("should evaluate payload strategy", func(t *testing.T) { + payload := `{"id":"1","order":{"qty":1,"deliver":{"expect":"2019-12-10","tracking":[{"date":"2019-12-09","status":"sent"},{"date":"2019-12-10","status":"delivered","comments":"comments"}]}}}` + + assert.True(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "HAS_ONE", []string{"login", "order.qty"}), payload)) + assert.False(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "HAS_ONE", []string{"user"}), payload)) + assert.True(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "HAS_ALL", []string{"id", "order", "order.qty"}), payload)) + assert.False(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "HAS_ALL", []string{"id", "missing"}), payload)) + assert.False(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "HAS_ALL", []string{}), "NOT_JSON")) + assert.False(t, processLocalStrategy(makeStrategy("PAYLOAD_VALIDATION", "INVALID_OP", []string{"id"}), payload)) + }) + + t.Run("should evaluate network strategy", func(t *testing.T) { + assert.True(t, processLocalStrategy(makeStrategy(StrategyNetwork, "EXIST", []string{"10.0.0.0/30"}), "10.0.0.3")) + assert.True(t, processLocalStrategy(makeStrategy(StrategyNetwork, "EXIST", []string{"10.0.0.3/24"}), "10.0.0.3")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyNetwork, "EXIST", []string{"10.0.0.0/30"}), "10.0.0.4")) + assert.True(t, processLocalStrategy(makeStrategy(StrategyNetwork, "NOT_EXIST", []string{"10.0.0.0/30"}), "10.0.0.4")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyNetwork, "NOT_EXIST", []string{"10.0.0.0/30"}), "10.0.0.3")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyNetwork, "EXIST", []string{"10.0.0.0/30"}), "not-an-ip")) + assert.False(t, processLocalStrategy(makeStrategy(StrategyNetwork, "INVALID_OP", []string{"10.0.0.0/30"}), "10.0.0.3")) + }) + + t.Run("should evaluate regex strategy", func(t *testing.T) { + assert.True(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "EXIST", []string{`\bUSER_[0-9]{1,2}\b`}), "USER_1")) + assert.False(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "EXIST", []string{`\bUSER_[0-9]{1,2}\b`}), "USER_123")) + assert.True(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "NOT_EXIST", []string{`\bUSER_[0-9]{1,2}\b`}), "USER_123")) + assert.True(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "EQUAL", []string{`USER_[0-9]{1,2}`}), "USER_11")) + assert.False(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "EQUAL", []string{`USER_[0-9]{1,2}`}), "USER_123")) + assert.True(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "NOT_EQUAL", []string{`USER_[0-9]{1,2}`}), "USER_123")) + assert.False(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "NOT_EQUAL", []string{`USER_[0-9]{1,2}`}), "USER_1")) + assert.False(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "EQUAL", []string{"["}), "USER_11")) + assert.False(t, processLocalStrategy(makeStrategy("REGEX_VALIDATION", "INVALID_OP", []string{`USER_[0-9]{1,2}`}), "USER_11")) + }) + + t.Run("should return false for unknown strategy", func(t *testing.T) { + assert.False(t, processLocalStrategy(makeStrategy("UNKNOWN", "EQUAL", []string{"USER_1"}), "USER_1")) + }) +} diff --git a/remote.go b/remote.go index a195bac..f1ea139 100644 --- a/remote.go +++ b/remote.go @@ -14,11 +14,6 @@ import ( "time" ) -const ( - StrategyValue = "VALUE_VALIDATION" - StrategyNetwork = "NETWORK_VALIDATION" -) - type criteriaEntry struct { Strategy string `json:"strategy"` Input string `json:"input"` diff --git a/resolver.go b/resolver.go index e835562..45fadd4 100644 --- a/resolver.go +++ b/resolver.go @@ -1,10 +1,5 @@ package client -import ( - "net" - "slices" -) - func checkLocalCriteria(snapshot *Snapshot, switcher *Switcher) (ResultDetail, error) { if snapshot == nil { return ResultDetail{}, newLocalCriteriaError("Snapshot not loaded. Try to use 'Client.load_snapshot()'") @@ -67,7 +62,7 @@ func checkLocalStrategies(strategies []SnapshotStrategy, entries []criteriaEntry } entry, ok := findCriteriaEntry(entries, strategy.Strategy) - if !ok || !evaluateLocalStrategy(strategy, entry.Input) { + if !ok || !processLocalStrategy(strategy, entry.Input) { return ResultDetail{Result: false, Reason: "Strategy '" + strategy.Strategy + "' does not agree", Metadata: map[string]any{}}, nil } } @@ -84,50 +79,3 @@ func findCriteriaEntry(entries []criteriaEntry, strategy string) (criteriaEntry, return criteriaEntry{}, false } - -func evaluateLocalStrategy(strategy SnapshotStrategy, input string) bool { - switch strategy.Strategy { - case StrategyValue: - switch strategy.Operation { - case "EXIST", "EQUAL": - return containsString(strategy.Values, input) - case "NOT_EXIST", "NOT_EQUAL": - return !containsString(strategy.Values, input) - } - case StrategyNetwork: - switch strategy.Operation { - case "EXIST": - return networkExists(strategy.Values, input) - case "NOT_EXIST": - return !networkExists(strategy.Values, input) - } - } - - return false -} - -func containsString(values []string, target string) bool { - return slices.Contains(values, target) -} - -func networkExists(values []string, input string) bool { - ip := net.ParseIP(input) - if ip == nil { - return false - } - - for _, value := range values { - if _, network, err := net.ParseCIDR(value); err == nil { - if network.Contains(ip) { - return true - } - continue - } - - if parsed := net.ParseIP(value); parsed != nil && parsed.Equal(ip) { - return true - } - } - - return false -}