From 6f92c9940e9afa9d969ed5a3e474c21797d59bb1 Mon Sep 17 00:00:00 2001 From: anthonpetrow Date: Sun, 14 Jul 2024 00:34:14 +0900 Subject: [PATCH 1/4] init engine go --- flipt-engine-go/.gitignore | 1 + flipt-engine-go/engine.go | 96 + flipt-engine-go/errors.go | 10 + flipt-engine-go/evaluator.go | 662 +++++ flipt-engine-go/evaluator_models.go | 117 + flipt-engine-go/evaluator_test.go | 3635 +++++++++++++++++++++++++++ flipt-engine-go/example/main.go | 30 + flipt-engine-go/go.mod | 15 + flipt-engine-go/go.sum | 16 + flipt-engine-go/http.go | 83 + flipt-engine-go/http_models.go | 121 + flipt-engine-go/http_test.go | 563 +++++ flipt-engine-go/mock.go | 271 ++ flipt-engine-go/snapshot.go | 233 ++ flipt-engine-go/snapshot_models.go | 62 + flipt-engine-go/snapshot_test.go | 1072 ++++++++ go.work | 1 + go.work.sum | 5 + 18 files changed, 6993 insertions(+) create mode 100644 flipt-engine-go/.gitignore create mode 100644 flipt-engine-go/engine.go create mode 100644 flipt-engine-go/errors.go create mode 100644 flipt-engine-go/evaluator.go create mode 100644 flipt-engine-go/evaluator_models.go create mode 100644 flipt-engine-go/evaluator_test.go create mode 100644 flipt-engine-go/example/main.go create mode 100644 flipt-engine-go/go.mod create mode 100644 flipt-engine-go/go.sum create mode 100644 flipt-engine-go/http.go create mode 100644 flipt-engine-go/http_models.go create mode 100644 flipt-engine-go/http_test.go create mode 100644 flipt-engine-go/mock.go create mode 100644 flipt-engine-go/snapshot.go create mode 100644 flipt-engine-go/snapshot_models.go create mode 100644 flipt-engine-go/snapshot_test.go diff --git a/flipt-engine-go/.gitignore b/flipt-engine-go/.gitignore new file mode 100644 index 00000000..485dee64 --- /dev/null +++ b/flipt-engine-go/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/flipt-engine-go/engine.go b/flipt-engine-go/engine.go new file mode 100644 index 00000000..125db919 --- /dev/null +++ b/flipt-engine-go/engine.go @@ -0,0 +1,96 @@ +package flipt_engine_go + +import ( + "context" + "errors" + "time" + + "go.uber.org/zap" +) + +type Config struct { + Enabled bool + Host string + Version string + Timeout time.Duration + Interval time.Duration + Namespaces []string +} + +type Engine struct { + cfg *Config + http *HTTPParser + snapshot *Snapshot + logger *zap.Logger +} + +func NewEngine( + cfg *Config, + http *HTTPParser, + snapshot *Snapshot, + logger *zap.Logger, + +) *Engine { + return &Engine{ + cfg: cfg, + http: http, + snapshot: snapshot, + logger: logger, + } +} + +func (r *Engine) Run(ctx context.Context) { + defer func() { + if p := recover(); p != nil { + r.logger.Error("panic occurred in flipt client-side engine", zap.Any("panic", p)) + } + }() + + // initial snapshot + err := r.replaceSnapshot(ctx) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + r.logger.Error("replace snapshot", zap.Error(err)) + } + + ticker := time.NewTicker(r.cfg.Interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := r.replaceSnapshot(ctx) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + r.logger.Error("replace snapshot", zap.Error(err)) + } + } + } +} + +func (r *Engine) replaceSnapshot(ctx context.Context) error { + r.snapshot.cleanStore() // clear from memory before making a backup + + for _, namespace := range r.cfg.Namespaces { + response, err := r.http.Do(ctx, namespace) + if err != nil { + return err + } + + doc, err := r.http.Parse(response) + if err != nil { + return err + } + + evalNamespace := r.snapshot.makeSnapshot(doc) + r.snapshot.replaceStore(evalNamespace) + } + + return nil +} diff --git a/flipt-engine-go/errors.go b/flipt-engine-go/errors.go new file mode 100644 index 00000000..2226dd1a --- /dev/null +++ b/flipt-engine-go/errors.go @@ -0,0 +1,10 @@ +package flipt_engine_go + +import ( + "errors" +) + +var ( + ErrNotFound = errors.New("not found") + ErrInvalid = errors.New("invalid") +) diff --git a/flipt-engine-go/evaluator.go b/flipt-engine-go/evaluator.go new file mode 100644 index 00000000..b6790064 --- /dev/null +++ b/flipt-engine-go/evaluator.go @@ -0,0 +1,662 @@ +package flipt_engine_go + +import ( + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" +) + +const ( + analyticsTopicKey = "topic" + analyticsTopicName = "analytics.prod.flipt_counter" + analyticsObjectKey = "analytics" + analyticsName = "flag_evaluation_count" +) + +var ( + evaluator *Evaluator + evaluatorMu sync.RWMutex +) + +type Evaluator struct { + snapshot *Snapshot + logger *zap.Logger +} + +func SetEvaluator(snapshot *Snapshot, logger *zap.Logger) { + evaluatorMu.Lock() + evaluator = &Evaluator{ + snapshot: snapshot, + logger: logger, + } + evaluatorMu.Unlock() +} + +func GetEvaluator() *Evaluator { + evaluatorMu.RLock() + eval := evaluator + evaluatorMu.RUnlock() + + return eval +} + +const ( + opEQ = "eq" + opNEQ = "neq" + opLT = "lt" + opLTE = "lte" + opGT = "gt" + opGTE = "gte" + opEmpty = "empty" + opNotEmpty = "notempty" + opTrue = "true" + opFalse = "false" + opPresent = "present" + opNotPresent = "notpresent" + opPrefix = "prefix" + opSuffix = "suffix" + opIsOneOf = "isoneof" + opIsNotOneOf = "isnotoneof" +) + +const ( + // totalBucketNum represents how many buckets we can use to determine the consistent hashing + // distribution and rollout + totalBucketNum uint = 1000 + + // percentMultiplier implies that the multiplier between percentage (100) and totalBucketNum + percentMultiplier = float32(totalBucketNum) / 100 +) + +func crc32Num(entityID string, salt string) uint { + return uint(crc32.ChecksumIEEE([]byte(salt+entityID))) % totalBucketNum +} + +func (r *Evaluator) Batch(request *RequestBatchEvaluation) (*ResponseBatchEvaluation, error) { + if r.snapshot == nil { + return nil, errors.New("snapshot is nil") + } + + result := &ResponseBatchEvaluation{ + Responses: make([]*ResponseEvaluation, 0, len(request.Requests)), + } + + for _, req := range request.Requests { + flag, err := r.snapshot.getFlag(req.NamespaceKey, req.FlagKey) + if err != nil { + if errors.Is(err, ErrNotFound) { + result.Responses = append(result.Responses, &ResponseEvaluation{ + Type: typeError, + ErrorResponse: &ResponseError{ + FlagKey: req.FlagKey, + NamespaceKey: req.NamespaceKey, + Reason: errorReasonNotFound, + }, + }) + + continue + } + return nil, fmt.Errorf("get flag: %w", err) + } + + switch flag.typ { + case variantFlagType: + variant, err := r.Variant(req, flag) + if err != nil { + return nil, fmt.Errorf("variant: %w", err) + } + result.Responses = append(result.Responses, &ResponseEvaluation{ + Type: typeVariant, + VariantResponse: variant, + }) + case booleanFlagType: + boolean, err := r.Boolean(req, flag) + if err != nil { + return nil, fmt.Errorf("boolean: %w", err) + } + result.Responses = append(result.Responses, &ResponseEvaluation{ + Type: typeBoolean, + BooleanResponse: boolean, + }) + default: + return nil, fmt.Errorf("unknown flag type: %s", flag.typ.String()) + } + } + + return result, nil +} + +func (r *Evaluator) ListFlags(namespaceKey string) (*ResponseListFlags, error) { + if r.snapshot == nil { + return nil, errors.New("snapshot is nil") + } + + result := &ResponseListFlags{Flags: make([]*ResponseFlag, 0)} + + evalFlags, err := r.snapshot.listFlags(namespaceKey) + if err != nil { + if errors.Is(err, ErrNotFound) { + return result, nil + } + } + + for _, evalFlag := range evalFlags { + result.Flags = append(result.Flags, &ResponseFlag{ + Key: evalFlag.key, + Enabled: evalFlag.enabled, + NamespaceKey: namespaceKey, + Type: evalFlag.typ, + }) + } + + return result, nil +} + +func (r *Evaluator) Boolean(req *RequestEvaluation, flag *EvalFlag) (*ResponseBoolean, error) { + eval, err := r.evalBoolean(req, flag) + if err != nil { + return nil, err + } + + r.logger.Info("dwh call", + zap.String(analyticsTopicKey, analyticsTopicName), + zap.Object(analyticsObjectKey, &Analytics{ + Timestamp: time.Now().UTC(), + AnalyticName: analyticsName, + NamespaceKey: req.NamespaceKey, + FlagKey: req.FlagKey, + FlagType: flag.typ, + Reason: eval.Reason, + EvaluationValue: strconv.FormatBool(eval.Enabled), + EntityID: req.EntityID, + Value: 1, + })) + + return eval, nil +} + +func (r *Evaluator) Variant(req *RequestEvaluation, flag *EvalFlag) (*ResponseVariant, error) { + eval, err := r.evalVariant(req, flag) + if err != nil { + return nil, err + } + + r.logger.Info("dwh call", + zap.String(analyticsTopicKey, analyticsTopicName), + zap.Object(analyticsObjectKey, &Analytics{ + Timestamp: time.Now().UTC(), + AnalyticName: analyticsName, + NamespaceKey: req.NamespaceKey, + FlagKey: req.FlagKey, + FlagType: flag.typ, + Reason: eval.Reason, + Match: &eval.Match, + EvaluationValue: eval.VariantKey, + EntityID: req.EntityID, + Value: 1, + })) + + return eval, nil +} + +func (r *Evaluator) evalBoolean(req *RequestEvaluation, flag *EvalFlag) (*ResponseBoolean, error) { + resp := &ResponseBoolean{ + FlagKey: req.FlagKey, + Enabled: false, + Reason: reasonDefault, + } + + if flag.typ != booleanFlagType { + resp.Reason = reasonError + return nil, errors.New("flag type is not boolean") + } + + if !flag.enabled { + resp.Reason = reasonDisabled + return resp, nil + } + + rollouts, err := r.snapshot.getRollouts(req.NamespaceKey, req.FlagKey) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.Enabled = flag.enabled + return resp, nil + } + return nil, fmt.Errorf("get rollouts: %w", err) + } + + var lastRank int + for _, rollout := range rollouts { + if rollout.rank < lastRank { + return nil, fmt.Errorf("rollout type %s rank: %d detected out of order", rollout.typ, rollout.rank) + } + lastRank = rollout.rank + + if rollout.threshold != nil { + hash := crc32.ChecksumIEEE([]byte(req.EntityID + req.FlagKey)) + + normalizedValue := float32(int(hash) % 100) + if normalizedValue < rollout.threshold.percentage { + resp.Enabled = rollout.threshold.value + resp.Reason = reasonMatch + return resp, nil + } + } else if rollout.segment != nil { + + var segmentMatches int + for _, segment := range rollout.segment.segments { + matched, err := r.matchConstraints(req.Context, segment.constraints, segment.matchType, req.EntityID) + if err != nil { + return nil, fmt.Errorf("match constraints, rollout type '%s', segment_key '%s': %w", rollout.typ, segment.key, err) + } + + if matched { + segmentMatches++ + } + } + + switch rollout.segment.segmentOperator { + case orSegmentOperator: + if segmentMatches < 1 { + continue + } + case andSegmentOperator: + if len(rollout.segment.segments) != segmentMatches { + continue + } + } + + resp.Enabled = rollout.segment.value + resp.Reason = reasonMatch + return resp, nil + } + } + + resp.Enabled = flag.enabled + return resp, nil +} + +func (r *Evaluator) evalVariant(req *RequestEvaluation, flag *EvalFlag) (*ResponseVariant, error) { + resp := &ResponseVariant{ + FlagKey: flag.key, + Match: false, + Reason: reasonUnknown, + SegmentKeys: []string{}, + } + + if flag.typ != variantFlagType { + resp.Reason = reasonError + return nil, errors.New("flag type is not variant") + } + + if !flag.enabled { + resp.Reason = reasonDisabled + return resp, nil + } + + rules, err := r.snapshot.getRules(req.NamespaceKey, req.FlagKey) + if err != nil { + if errors.Is(err, ErrNotFound) { + return resp, nil + } + return nil, fmt.Errorf("get rules: %w", err) + } + + var lastRank int + for _, rule := range rules { + if rule.rank < lastRank { + resp.Reason = reasonError + return nil, fmt.Errorf("rule_id '%s' rank '%d' detected out of order", rule.id, rule.rank) + } + + lastRank = rule.rank + + segmentKeys := make([]string, 0, len(rule.segments)) + segmentMatches := 0 + + // тут пока всегда будет только один сегмент т к мультисегментация в снапшоте работает неправильно + for _, segment := range rule.segments { + matched, err := r.matchConstraints(req.Context, segment.constraints, segment.matchType, req.EntityID) + if err != nil { + resp.Reason = reasonError + return nil, fmt.Errorf("match constraints, rule_id '%s', segment_key '%s: %w", rule.id, segment.key, err) + } + + if matched { + segmentKeys = append(segmentKeys, segment.key) + segmentMatches++ + } + } + + switch rule.segmentOperator { + case orSegmentOperator: + if segmentMatches < 1 { + continue + } + case andSegmentOperator: + if len(rule.segments) != segmentMatches { + continue + } + } + + if len(segmentKeys) > 0 { + resp.SegmentKeys = segmentKeys + } + + var ( + validDistributions []*EvalDistribution + buckets []int + ) + + for _, distribution := range rule.distributions { + // don't include 0% rollouts + if distribution.rollout > 0 { + validDistributions = append(validDistributions, distribution) + + if buckets == nil { + bucket := int(distribution.rollout * percentMultiplier) + buckets = append(buckets, bucket) + } else { + bucket := buckets[len(buckets)-1] + int(distribution.rollout*percentMultiplier) + buckets = append(buckets, bucket) + } + } + } + + // no distributions for rule + if len(validDistributions) == 0 { + resp.Match = true + resp.Reason = reasonMatch + return resp, nil + } + + var ( + bucket = crc32Num(req.EntityID, flag.key) + // sort.SearchInts searches for x in a sorted slice of ints and returns the index + // as specified by Search. The return value is the index to insert x if x is + // not present (it could be len(a)). + index = sort.SearchInts(buckets, int(bucket)+1) + ) + + // if index is outside of our existing buckets then it does not match any distribution + if index == len(validDistributions) { + return resp, nil + } + + d := validDistributions[index] + + resp.Match = true + resp.VariantKey = d.variantKey + resp.VariantAttachment = d.variantAttachment + resp.Reason = reasonMatch + return resp, nil + } // end rule loop + + return resp, nil +} + +func (r *Evaluator) matchConstraints( + evalCtx map[string]string, + constraints []*EvalConstraint, + segmentMatchType segmentMatchType, + entityId string, +) (bool, error) { + + constraintMatches := 0 + for _, constraint := range constraints { + property := evalCtx[constraint.property] + + var ( + match bool + err error + ) + + switch constraint.typ { + case stringConstraintComparisonType: + match = matchesString(constraint, property) + case numberConstraintComparisonType: + match, err = matchesNumber(constraint, property) + case booleanConstraintComparisonType: + match, err = matchesBool(constraint, property) + case datetimeConstraintComparisonType: + match, err = matchesDateTime(constraint, property) + case entityIDConstraintComparisonType: + match = matchesString(constraint, entityId) + default: + return false, fmt.Errorf("unknown constraint type: %s", constraint.typ) + } + + if err != nil { + r.logger.Error("error matching constraint", zap.String("property", constraint.property), zap.Error(err)) + // don't return here because we want to continue to evaluate the other constraints + } + + if match { + // increase the matchCount + constraintMatches++ + + switch segmentMatchType { + case anySegmentMatchType: + // can short circuit here since we had at least one match + break + default: + // keep looping as we need to match all constraints + continue + } + } else { + // no match + switch segmentMatchType { + case allSegmentMatchType: + // we can short circuit because we must match all constraints + break + default: + // keep looping to see if we match the next constraint + continue + } + } + } + + var matched = true + + switch segmentMatchType { + case allSegmentMatchType: + if len(constraints) != constraintMatches { + matched = false + } + + case anySegmentMatchType: + if len(constraints) > 0 && constraintMatches == 0 { + matched = false + } + default: + matched = false + } + + return matched, nil +} + +func matchesString(constraint *EvalConstraint, property string) bool { + switch constraint.operator { + case opEmpty: + return len(strings.TrimSpace(property)) == 0 + case opNotEmpty: + return len(strings.TrimSpace(property)) != 0 + } + + if property == "" { + return false + } + + value := constraint.value + + switch constraint.operator { + case opEQ: + return value == property + case opNEQ: + return value != property + case opPrefix: + return strings.HasPrefix(strings.TrimSpace(property), value) + case opSuffix: + return strings.HasSuffix(strings.TrimSpace(property), value) + case opIsOneOf: + values := []string{} + if err := json.Unmarshal([]byte(value), &values); err != nil { + return false + } + return slices.Contains(values, property) + case opIsNotOneOf: + values := []string{} + if err := json.Unmarshal([]byte(value), &values); err != nil { + return false + } + return !slices.Contains(values, property) + } + + return false +} + +func matchesNumber(constraint *EvalConstraint, property string) (bool, error) { + switch constraint.operator { + case opNotPresent: + return len(strings.TrimSpace(property)) == 0, nil + case opPresent: + return len(strings.TrimSpace(property)) != 0, nil + } + + // can't parse an empty string + if property == "" { + return false, nil + } + + n, err := strconv.ParseFloat(property, 64) + if err != nil { + return false, fmt.Errorf("parsing number from %s: %w", property, ErrInvalid) + } + + if constraint.operator == opIsOneOf { + values := []float64{} + if err := json.Unmarshal([]byte(constraint.value), &values); err != nil { + return false, fmt.Errorf("invalid value for constraint %s: %w", constraint.value, ErrInvalid) + } + return slices.Contains(values, n), nil + } else if constraint.operator == opIsNotOneOf { + values := []float64{} + if err := json.Unmarshal([]byte(constraint.value), &values); err != nil { + return false, fmt.Errorf("invalid value for constraint %s: %w", constraint.value, ErrInvalid) + } + return !slices.Contains(values, n), nil + } + + // TODO: we should consider parsing this at creation time since it doesn't change and it doesnt make sense to allow invalid constraint values + value, err := strconv.ParseFloat(constraint.value, 64) + if err != nil { + return false, fmt.Errorf("parsing number from %s: %w", constraint.value, ErrInvalid) + } + + switch constraint.operator { + case opEQ: + return value == n, nil + case opNEQ: + return value != n, nil + case opLT: + return n < value, nil + case opLTE: + return n <= value, nil + case opGT: + return n > value, nil + case opGTE: + return n >= value, nil + } + + return false, nil +} + +func matchesBool(constraint *EvalConstraint, property string) (bool, error) { + switch constraint.operator { + case opNotPresent: + return len(strings.TrimSpace(property)) == 0, nil + case opPresent: + return len(strings.TrimSpace(property)) != 0, nil + } + + // can't parse an empty string + if property == "" { + return false, nil + } + + value, err := strconv.ParseBool(property) + if err != nil { + return false, fmt.Errorf("parsing boolean from %s: %w", property, ErrInvalid) + } + + switch constraint.operator { + case opTrue: + return value, nil + case opFalse: + return !value, nil + } + + return false, nil +} + +func matchesDateTime(constraint *EvalConstraint, property string) (bool, error) { + switch constraint.operator { + case opNotPresent: + return len(strings.TrimSpace(property)) == 0, nil + case opPresent: + return len(strings.TrimSpace(property)) != 0, nil + } + + // can't parse an empty string + if property == "" { + return false, nil + } + + d, err := tryParseDateTime(property) + if err != nil { + return false, err + } + + value, err := tryParseDateTime(constraint.value) + if err != nil { + return false, err + } + + switch constraint.operator { + case opEQ: + return value.Equal(d), nil + case opNEQ: + return !value.Equal(d), nil + case opLT: + return d.Before(value), nil + case opLTE: + return d.Before(value) || value.Equal(d), nil + case opGT: + return d.After(value), nil + case opGTE: + return d.After(value) || value.Equal(d), nil + } + + return false, nil +} + +func tryParseDateTime(v string) (time.Time, error) { + if d, err := time.Parse(time.RFC3339, v); err == nil { + return d.UTC(), nil + } + + if d, err := time.Parse(time.DateOnly, v); err == nil { + return d.UTC(), nil + } + + return time.Time{}, fmt.Errorf("parsing datetime from %s: %w", v, ErrInvalid) +} diff --git a/flipt-engine-go/evaluator_models.go b/flipt-engine-go/evaluator_models.go new file mode 100644 index 00000000..02ac746c --- /dev/null +++ b/flipt-engine-go/evaluator_models.go @@ -0,0 +1,117 @@ +package flipt_engine_go + +import ( + "time" + + "go.uber.org/zap/zapcore" +) + +const ( + reasonDisabled evalReason = "FLAG_DISABLED_EVALUATION_REASON" + reasonMatch evalReason = "MATCH_EVALUATION_REASON" + reasonDefault evalReason = "DEFAULT_EVALUATION_REASON" + reasonUnknown evalReason = "UNKNOWN_EVALUATION_REASON" + reasonError evalReason = "ERROR_EVALUATION_REASON" + + errorReasonUnknown errorEvalReason = "UNKNOWN_ERROR_EVALUATION_REASON" + errorReasonNotFound errorEvalReason = "NOT_FOUND_ERROR_EVALUATION_REASON" + errorReasonNotImplemented errorEvalReason = "NOT_IMPLEMENTED_ERROR_EVALUATION_REASON" + + typeVariant evalType = "VARIANT_EVALUATION_RESPONSE_TYPE" + typeBoolean evalType = "BOOLEAN_EVALUATION_RESPONSE_TYPE" + typeError evalType = "ERROR_EVALUATION_RESPONSE_TYPE" +) + +type evalReason string + +func (r evalReason) String() string { + return string(r) +} + +type errorEvalReason string + +type evalType string + +type ResponseFlag struct { + Key string `json:"key"` + Enabled bool `json:"enabled"` + NamespaceKey string `json:"namespaceKey"` + Type flagType `json:"type"` +} + +type RequestEvaluation struct { + NamespaceKey string `json:"namespaceKey"` + FlagKey string `json:"flagKey"` + EntityID string `json:"entityId"` + Context map[string]string `json:"context"` +} + +type ResponseEvaluation struct { + Type evalType `json:"type"` + ErrorResponse *ResponseError `json:"errorResponse,omitempty"` + VariantResponse *ResponseVariant `json:"variantResponse,omitempty"` + BooleanResponse *ResponseBoolean `json:"booleanResponse,omitempty"` +} + +type ResponseError struct { + FlagKey string `json:"flagKey"` + NamespaceKey string `json:"namespaceKey"` + Reason errorEvalReason `json:"reason"` +} + +type ResponseVariant struct { + FlagKey string `json:"flagKey"` + Match bool `json:"match"` + SegmentKeys []string `json:"segmentKeys"` + Reason evalReason `json:"reason"` + VariantKey string `json:"variantKey"` + VariantAttachment string `json:"variantAttachment"` +} + +type ResponseBoolean struct { + FlagKey string `json:"flagKey"` + Enabled bool `json:"enabled"` + Reason evalReason `json:"reason"` +} + +type RequestBatchEvaluation struct { + Requests []*RequestEvaluation `json:"requests"` +} + +type ResponseBatchEvaluation struct { + Responses []*ResponseEvaluation `json:"responses"` +} + +type ResponseListFlags struct { + Flags []*ResponseFlag `json:"flags"` +} + +type Analytics struct { + Timestamp time.Time + AnalyticName string + NamespaceKey string + FlagKey string + FlagType flagType + Reason evalReason + Match *bool // server-side пишет пустую строку в тип BOOLEAN_FLAG_TYPE + EvaluationValue string + EntityID string + Value uint32 +} + +func (r *Analytics) MarshalLogObject(enc zapcore.ObjectEncoder) error { + enc.AddTime("timestamp", r.Timestamp) + enc.AddString("analytic_name", r.AnalyticName) + enc.AddString("namespace_key", r.NamespaceKey) + enc.AddString("flag_key", r.FlagKey) + enc.AddString("flag_type", r.FlagType.String()) + enc.AddString("reason", r.Reason.String()) + if r.Match != nil { + enc.AddBool("match", *r.Match) + } + enc.AddString("evaluation_value", r.EvaluationValue) + enc.AddString("entity_id", r.EntityID) + enc.AddUint32("value", r.Value) + + return nil +} diff --git a/flipt-engine-go/evaluator_test.go b/flipt-engine-go/evaluator_test.go new file mode 100644 index 00000000..fe22a8fa --- /dev/null +++ b/flipt-engine-go/evaluator_test.go @@ -0,0 +1,3635 @@ +package flipt_engine_go + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap/zaptest" +) + +func Test_matchesDateTime(t *testing.T) { + tests := []struct { + name string + constraint *EvalConstraint + value string + wantMatch bool + wantErr bool + }{ + { + name: "present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + value: "2006-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + }, + { + name: "not present", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + wantMatch: true, + }, + { + name: "negative notpresent", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + value: "2006-01-02T15:04:05Z", + }, + { + name: "not a datetime constraint value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "bar", + }, + value: "2006-01-02T15:04:05Z", + wantErr: true, + }, + { + name: "not a datetime context value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "2006-01-02T15:04:05Z", + }, + value: "foo", + wantErr: true, + }, + { + name: "eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "2006-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "eq date only", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "2006-01-02", + }, + value: "2006-01-02", + wantMatch: true, + }, + { + name: "negative eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "2006-01-02T15:04:05Z", + }, + value: "2007-01-02T15:04:05Z", + }, + { + name: "neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "2006-01-02T15:04:05Z", + }, + value: "2007-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "2006-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + }, + { + name: "negative neq date only", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "2006-01-02", + }, + value: "2006-01-02", + }, + { + name: "lt", + constraint: &EvalConstraint{ + property: "foo", + operator: "lt", + value: "2006-01-02T15:04:05Z", + }, + value: "2005-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative lt", + constraint: &EvalConstraint{ + property: "foo", + operator: "lt", + value: "2005-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + }, + { + name: "lte", + constraint: &EvalConstraint{ + property: "foo", + operator: "lte", + value: "2006-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative lte", + constraint: &EvalConstraint{ + property: "foo", + operator: "lte", + value: "2006-01-02T15:04:05Z", + }, + value: "2007-01-02T15:04:05Z", + }, + { + name: "gt", + constraint: &EvalConstraint{ + property: "foo", + operator: "gt", + value: "2006-01-02T15:04:05Z", + }, + value: "2007-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative gt", + constraint: &EvalConstraint{ + property: "foo", + operator: "gt", + value: "2007-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + }, + { + name: "gte", + constraint: &EvalConstraint{ + property: "foo", + operator: "gte", + value: "2006-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + wantMatch: true, + }, + { + name: "negative gte", + constraint: &EvalConstraint{ + property: "foo", + operator: "gte", + value: "2006-01-02T15:04:05Z", + }, + value: "2005-01-02T15:04:05Z", + }, + { + name: "empty value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "2006-01-02T15:04:05Z", + }, + }, + { + name: "unknown operator", + constraint: &EvalConstraint{ + property: "foo", + operator: "foo", + value: "2006-01-02T15:04:05Z", + }, + value: "2006-01-02T15:04:05Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + constraint = tt.constraint + value = tt.value + wantMatch = tt.wantMatch + wantErr = tt.wantErr + ) + + match, err := matchesDateTime(constraint, value) + + if wantErr { + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalid) + return + } + + assert.NoError(t, err) + assert.Equal(t, wantMatch, match) + }) + + } +} + +func Test_matchesBool(t *testing.T) { + tests := []struct { + name string + constraint *EvalConstraint + value string + wantMatch bool + wantErr bool + }{ + { + name: "present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + value: "true", + wantMatch: true, + }, + { + name: "negative present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + }, + { + name: "not present", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + wantMatch: true, + }, + { + name: "negative notpresent", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + value: "true", + }, + { + name: "not a bool", + constraint: &EvalConstraint{ + property: "foo", + operator: "true", + }, + value: "foo", + wantErr: true, + }, + { + name: "is true", + constraint: &EvalConstraint{ + property: "foo", + operator: "true", + }, + value: "true", + wantMatch: true, + }, + { + name: "negative is true", + constraint: &EvalConstraint{ + property: "foo", + operator: "true", + }, + value: "false", + }, + { + name: "is false", + constraint: &EvalConstraint{ + property: "foo", + operator: "false", + }, + value: "false", + wantMatch: true, + }, + { + name: "negative is false", + constraint: &EvalConstraint{ + property: "foo", + operator: "false", + }, + value: "true", + }, + { + name: "empty value", + constraint: &EvalConstraint{ + property: "foo", + operator: "false", + }, + }, + { + name: "unknown operator", + constraint: &EvalConstraint{ + property: "foo", + operator: "foo", + }, + value: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + constraint = tt.constraint + value = tt.value + wantMatch = tt.wantMatch + wantErr = tt.wantErr + ) + + match, err := matchesBool(constraint, value) + + if wantErr { + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalid) + return + } + + assert.NoError(t, err) + assert.Equal(t, wantMatch, match) + }) + } +} + +func Test_matchesNumber(t *testing.T) { + tests := []struct { + name string + constraint *EvalConstraint + value string + wantMatch bool + wantErr bool + }{ + { + name: "present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + value: "1", + wantMatch: true, + }, + { + name: "negative present", + constraint: &EvalConstraint{ + property: "foo", + operator: "present", + }, + }, + { + name: "not present", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + wantMatch: true, + }, + { + name: "negative notpresent", + constraint: &EvalConstraint{ + property: "foo", + operator: "notpresent", + }, + value: "1", + }, + { + name: "NAN constraint value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "bar", + }, + value: "5", + wantErr: true, + }, + { + name: "NAN context value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "5", + }, + value: "foo", + wantErr: true, + }, + { + name: "eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "42.0", + }, + value: "42.0", + wantMatch: true, + }, + { + name: "negative eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "42.0", + }, + value: "50", + }, + { + name: "neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "42.0", + }, + value: "50", + wantMatch: true, + }, + { + name: "negative neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "42.0", + }, + value: "42.0", + }, + { + name: "lt", + constraint: &EvalConstraint{ + property: "foo", + operator: "lt", + value: "42.0", + }, + value: "8", + wantMatch: true, + }, + { + name: "negative lt", + constraint: &EvalConstraint{ + property: "foo", + operator: "lt", + value: "42.0", + }, + value: "50", + }, + { + name: "lte", + constraint: &EvalConstraint{ + property: "foo", + operator: "lte", + value: "42.0", + }, + value: "42.0", + wantMatch: true, + }, + { + name: "negative lte", + constraint: &EvalConstraint{ + property: "foo", + operator: "lte", + value: "42.0", + }, + value: "102.0", + }, + { + name: "gt", + constraint: &EvalConstraint{ + property: "foo", + operator: "gt", + value: "10.11", + }, + value: "10.12", + wantMatch: true, + }, + { + name: "negative gt", + constraint: &EvalConstraint{ + property: "foo", + operator: "gt", + value: "10.11", + }, + value: "1", + }, + { + name: "gte", + constraint: &EvalConstraint{ + property: "foo", + operator: "gte", + value: "10.11", + }, + value: "10.11", + wantMatch: true, + }, + { + name: "negative gte", + constraint: &EvalConstraint{ + property: "foo", + operator: "gte", + value: "10.11", + }, + value: "0.11", + }, + { + name: "empty value", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "0.11", + }, + }, + { + name: "unknown operator", + constraint: &EvalConstraint{ + property: "foo", + operator: "foo", + value: "0.11", + }, + value: "0.11", + }, + { + name: "negative suffix empty value", + constraint: &EvalConstraint{ + property: "foo", + operator: "suffix", + value: "bar", + }, + }, + { + name: "is one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[3, 3.14159, 4]", + }, + value: "3.14159", + wantMatch: true, + }, + { + name: "negative is one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[5, 3.14159, 4]", + }, + value: "9", + }, + { + name: "negative is one of (non-number values)", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[5, \"str\"]", + }, + value: "5", + wantErr: true, + }, + { + name: "is not one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isnotoneof", + value: "[5, 3.14159, 4]", + }, + value: "3", + wantMatch: true, + }, + { + name: "negative is not one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isnotoneof", + value: "[5, 3.14159, 4]", + }, + value: "3.14159", + wantMatch: false, + }, + { + name: "negative is not one of (invalid json)", + constraint: &EvalConstraint{ + property: "foo", + operator: "isnotoneof", + value: "[5, 6", + }, + value: "5", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + constraint = tt.constraint + value = tt.value + wantMatch = tt.wantMatch + wantErr = tt.wantErr + ) + + match, err := matchesNumber(constraint, value) + + if wantErr { + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalid) + return + } + + assert.NoError(t, err) + assert.Equal(t, wantMatch, match) + }) + } +} + +func Test_matchesString(t *testing.T) { + tests := []struct { + name string + constraint *EvalConstraint + value string + wantMatch bool + }{ + { + name: "eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "bar", + }, + value: "bar", + wantMatch: true, + }, + { + name: "negative eq", + constraint: &EvalConstraint{ + property: "foo", + operator: "eq", + value: "bar", + }, + value: "baz", + }, + { + name: "neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "bar", + }, + value: "baz", + wantMatch: true, + }, + { + name: "negative neq", + constraint: &EvalConstraint{ + property: "foo", + operator: "neq", + value: "bar", + }, + value: "bar", + }, + { + name: "empty", + constraint: &EvalConstraint{ + property: "foo", + operator: "empty", + }, + value: " ", + wantMatch: true, + }, + { + name: "negative empty", + constraint: &EvalConstraint{ + property: "foo", + operator: "empty", + }, + value: "bar", + }, + { + name: "not empty", + constraint: &EvalConstraint{ + property: "foo", + operator: "notempty", + }, + value: "bar", + wantMatch: true, + }, + { + name: "negative not empty", + constraint: &EvalConstraint{ + property: "foo", + operator: "notempty", + }, + value: "", + }, + { + name: "unknown operator", + constraint: &EvalConstraint{ + property: "foo", + operator: "foo", + value: "bar", + }, + value: "bar", + }, + { + name: "prefix", + constraint: &EvalConstraint{ + property: "foo", + operator: "prefix", + value: "ba", + }, + value: "bar", + wantMatch: true, + }, + { + name: "negative prefix", + constraint: &EvalConstraint{ + property: "foo", + operator: "prefix", + value: "bar", + }, + value: "nope", + }, + { + name: "suffix", + constraint: &EvalConstraint{ + property: "foo", + operator: "suffix", + value: "ar", + }, + value: "bar", + wantMatch: true, + }, + { + name: "negative suffix", + constraint: &EvalConstraint{ + property: "foo", + operator: "suffix", + value: "bar", + }, + value: "nope", + }, + { + name: "is one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[\"bar\", \"baz\"]", + }, + value: "baz", + wantMatch: true, + }, + { + name: "negative is one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[\"bar\", \"baz\"]", + }, + value: "nope", + }, + { + name: "negative is one of (invalid json)", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[\"bar\", \"baz\"", + }, + value: "bar", + }, + { + name: "negative is one of (non-string values)", + constraint: &EvalConstraint{ + property: "foo", + operator: "isoneof", + value: "[\"bar\", 5]", + }, + value: "bar", + }, + { + name: "is not one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isnotoneof", + value: "[\"bar\", \"baz\"]", + }, + value: "baz", + }, + { + name: "negative is not one of", + constraint: &EvalConstraint{ + property: "foo", + operator: "isnotoneof", + value: "[\"bar\", \"baz\"]", + }, + value: "nope", + wantMatch: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + constraint = tt.constraint + value = tt.value + wantMatch = tt.wantMatch + ) + + match := matchesString(constraint, value) + assert.Equal(t, wantMatch, match) + }) + } +} + +func TestEvaluator_matchConstraints(t *testing.T) { + type args struct { + evalCtx map[string]string + constraints []*EvalConstraint + segmentMatchType segmentMatchType + entityId string + } + tests := []struct { + name string + args args + match bool + wantErr error + }{ + { + name: "isMatch: constraints is empty", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + }, + constraints: nil, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "isMatch: one constraint ANY segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "notMatch: one constraint ANY segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "isMatch: one constraint ALL segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "notMatch: one constraint ALL segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "isMatch: two constraint ANY segment, city_id is eq", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "isMatch: two constraint ANY segment, country_id is eq", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + "country_id": "1", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "notMatch: two constraint ANY segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: anySegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "notMatch: two constraint ALL segment, country_id and city_id is not eq", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "notMatch: two constraint ALL segment, country_id is not eq", + args: args{ + evalCtx: map[string]string{ + "city_id": "2", + "country_id": "1", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "notMatch: two constraint ALL segment, city_id is not eq", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + { + name: "isMatch: two constraint ALL segment", + args: args{ + evalCtx: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "isMatch: property is bool", + args: args{ + evalCtx: map[string]string{ + "mode": "true", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: booleanConstraintComparisonType, + property: "mode", + operator: "true", + value: "true", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: true, + wantErr: nil, + }, + { + name: "notMatch: property is bool", + args: args{ + evalCtx: map[string]string{ + "mode": "false", + }, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: booleanConstraintComparisonType, + property: "mode", + operator: "true", + value: "true", + }, + }, + segmentMatchType: allSegmentMatchType, + entityId: "77777", + }, + match: false, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Evaluator{ + snapshot: NewSnapshot(), + logger: zaptest.NewLogger(t), + } + + match, err := r.matchConstraints(tt.args.evalCtx, tt.args.constraints, tt.args.segmentMatchType, tt.args.entityId) + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + assert.Equalf(t, tt.match, match, "matchConstraints(%v, %v, %v, %v)", tt.args.evalCtx, tt.args.constraints, tt.args.segmentMatchType, tt.args.entityId) + } + }) + } +} + +func TestEvaluator_variant(t *testing.T) { + type args struct { + req *RequestEvaluation + flag *EvalFlag + } + type mock struct { + namespaces map[string]*EvalNamespace + } + type want struct { + want *ResponseVariant + wantErr error + } + tests := []struct { + name string + args + mock + want + }{ + { + name: "flag type is not variant", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: booleanFlagType, + enabled: false, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("flag type is not variant"), + }, + }, + { + name: "flag is disabled", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: false, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonDisabled, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "empty rules", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: nil, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonUnknown, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "rules out of order", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "2", + }, + }, + }, + }, + }, + { + id: "2", + rank: 0, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment2", + matchType: allSegmentMatchType, + constraints: nil, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("rule_id '2' rank '0' detected out of order"), + }, + }, + { + name: "match = true (1 segment, 0 constraints, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: nil, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = true (1 segment, 1 constraint, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = true (1 segment, 2 constraint ALL segment operator, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = false, country_id is not eq (1 segment, 2 constraint ALL segment operator, 0 distributions)", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonUnknown, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = false, city_id is not eq (1 segment, 2 constraint ALL segment operator, 0 distributions)", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "2", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonUnknown, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = false, all context is not eq (1 segment, 2 constraint ALL segment operator, 0 distributions)", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonUnknown, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = true, city_id is eq (1 segment, 2 constraint ANY segment operator, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = true, country_id is eq (1 segment, 2 constraint ANY segment operator, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "2", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = false, all context is not eq (1 segment, 2 constraint ANY segment operator, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: false, + SegmentKeys: []string{}, + Reason: reasonUnknown, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "match = false, all context is eq (1 segment, 2 constraint ANY segment operator, 0 distributions", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "", + VariantAttachment: "", + }, + wantErr: nil, + }, + }, + { + name: "error in match constraints", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: unknownIDConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + { + id: "2", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("match constraints, rule_id '1', segment_key 'segment1: unknown constraint type: UNKNOWN_CONSTRAINT_COMPARISON_TYPE"), + }, + }, + { + name: "match = true, single distribution", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "777323", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "1", + rollout: 100, + variantKey: "var1", + variantAttachment: "attach1", + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "var1", + VariantAttachment: "attach1", + }, + wantErr: nil, + }, + }, + { + name: "match = true, multi distribution (variant 1)", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "mobile_toggle1", + EntityID: "4", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "mobile_toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "mobile_toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "1", + rollout: 50, + variantKey: "var1", + variantAttachment: "attach1", + }, + { + ruleID: "1", + rollout: 50, + variantKey: "var2", + variantAttachment: "attach2", + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "mobile_toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "var1", + VariantAttachment: "attach1", + }, + wantErr: nil, + }, + }, + { + name: "match = true, multi distribution (variant 2)", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "mobile_toggle1", + EntityID: "1", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "mobile_toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + rules: map[string][]*EvalRule{ + "mobile_toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "1", + rollout: 50, + variantKey: "var1", + variantAttachment: "attach1", + }, + { + ruleID: "1", + rollout: 50, + variantKey: "var2", + variantAttachment: "attach2", + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseVariant{ + FlagKey: "mobile_toggle1", + Match: true, + SegmentKeys: []string{"segment1"}, + Reason: reasonMatch, + VariantKey: "var2", + VariantAttachment: "attach2", + }, + wantErr: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Evaluator{ + snapshot: NewSnapshot(), + logger: zaptest.NewLogger(t), + } + r.snapshot.replaceStore(tt.mock.namespaces) + + response, err := r.evalVariant(tt.args.req, tt.args.flag) + if tt.want.wantErr != nil { + assert.EqualError(t, err, tt.want.wantErr.Error()) + assert.Equal(t, tt.want.want, response) + } else { + assert.NoError(t, err) + assert.Equalf(t, tt.want.want, response, "variant(%v, %v)", tt.args.req, tt.args.flag) + } + }) + } +} + +func TestEvaluator_boolean(t *testing.T) { + type args struct { + req *RequestEvaluation + flag *EvalFlag + } + type mock struct { + namespaces map[string]*EvalNamespace + } + type want struct { + want *ResponseBoolean + wantErr error + } + tests := []struct { + name string + args + mock + want + }{ + { + name: "flag type is not boolean", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: variantFlagType, + enabled: false, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("flag type is not boolean"), + }, + }, + { + name: "flag is disabled", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: booleanFlagType, + enabled: false, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "toggle1", + Enabled: false, + Reason: reasonDisabled, + }, + wantErr: nil, + }, + }, + { + name: "rollouts not found", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: booleanFlagType, + enabled: true, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "toggle1", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "rollouts len is 0", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "mobile", + FlagKey: "toggle1", + EntityID: "77777", + Context: map[string]string{ + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "toggle1", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "mobile": { + key: "toggle1", + rollouts: map[string][]*EvalRollout{ + "toggle1": {}, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "toggle1", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "default rule fallthrough with percentage rollout", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 5, + value: false, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "percentage rule match", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 70, + value: false, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "the first threshold rollout is match", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 70, + value: false, + }, + }, + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "the second segment rollout is match", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 65, + value: false, + }, + }, + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "hello", + operator: "eq", + value: "world", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment match, multiple constraints any operator", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + "city_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "hello", + operator: "eq", + value: "world", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment not match, multiple constraints any operator", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "worl2d", + "city_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "hello", + operator: "eq", + value: "world", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "segment match, multiple constraints with all operator", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + "city_id": "1", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "hello", + operator: "eq", + value: "world", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment not match, multiple constraints with all operator", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "hello": "world", + "city_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: stringConstraintComparisonType, + property: "hello", + operator: "eq", + value: "world", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "segment is match, entity_id constraint", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{}, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: entityIDConstraintComparisonType, + property: "entity", + operator: "eq", + value: "test-entity", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment is match, multiple segment operator AND", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: andSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment is not match, multiple segment operator AND", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: andSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "segment is match, multiple segment operator OR", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "1", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment is not match, multiple segment operator OR", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: true, + Reason: reasonDefault, + }, + wantErr: nil, + }, + }, + { + name: "rules out of order", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 5, + value: false, + }, + }, + { + typ: segmentRolloutType, + rank: 0, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("rollout type SEGMENT_ROLLOUT_TYPE rank: 0 detected out of order"), + }, + }, + { + name: "unknown match constraint type", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "2", + "country_id": "2", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 0, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: unknownIDConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: nil, + wantErr: errors.New("match constraints, rollout type 'SEGMENT_ROLLOUT_TYPE', segment_key 'segment1': unknown constraint type: UNKNOWN_CONSTRAINT_COMPARISON_TYPE"), + }, + }, + { + name: "segment is match, duplicate constraint operator OR", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + { + name: "segment is match, duplicate constraint operator ALL", + args: args{ + req: &RequestEvaluation{ + NamespaceKey: "test-namespace", + FlagKey: "test-flag", + EntityID: "test-entity", + Context: map[string]string{ + "city_id": "1", + "country_id": "1", + }, + }, + flag: &EvalFlag{ + key: "test-flag", + typ: booleanFlagType, + enabled: true, + }, + }, + mock: mock{ + namespaces: map[string]*EvalNamespace{ + "test-namespace": { + key: "test-flag", + rollouts: map[string][]*EvalRollout{ + "test-flag": { + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: andSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "25af15c6-e6d9-4b7c-a046-d58ee36b1a35", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "segment2", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + { + id: "a2cab611-8289-4273-9cd8-53d6a418acd8", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + want: &ResponseBoolean{ + FlagKey: "test-flag", + Enabled: false, + Reason: reasonMatch, + }, + wantErr: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Evaluator{ + snapshot: NewSnapshot(), + logger: zaptest.NewLogger(t), + } + r.snapshot.replaceStore(tt.mock.namespaces) + + response, err := r.Boolean(tt.args.req, tt.args.flag) + if tt.want.wantErr != nil { + assert.EqualError(t, err, tt.want.wantErr.Error()) + assert.Equal(t, tt.want.want, response) + } else { + assert.NoError(t, err) + assert.Equalf(t, tt.want.want, response, "variant(%v, %v)", tt.args.req, tt.args.flag) + } + }) + } +} diff --git a/flipt-engine-go/example/main.go b/flipt-engine-go/example/main.go new file mode 100644 index 00000000..a152977e --- /dev/null +++ b/flipt-engine-go/example/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + + "go.uber.org/zap" + + "go.flipt.io/flipt-engine-go" +) + +func main() { + cfg := &flipt_engine_go.Config{ + Enabled: false, + Host: "", + Version: "", + Timeout: 0, + Interval: 0, + Namespaces: nil, + } + + logger := zap.NewNop() + http := flipt_engine_go.NewHTTPParser(cfg.Host, cfg.Version, cfg.Timeout) + snapshot := flipt_engine_go.NewSnapshot() + flipt_engine_go.SetEvaluator(snapshot, logger) + + ctx := context.Background() + + engine := flipt_engine_go.NewEngine(cfg, http, snapshot, logger.With(zap.String("worker", "flipt_client_side"))) + engine.Run(ctx) +} diff --git a/flipt-engine-go/go.mod b/flipt-engine-go/go.mod new file mode 100644 index 00000000..6742bd43 --- /dev/null +++ b/flipt-engine-go/go.mod @@ -0,0 +1,15 @@ +module go.flipt.io/flipt-engine-go + +go 1.22.1 + +require ( + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/flipt-engine-go/go.sum b/flipt-engine-go/go.sum new file mode 100644 index 00000000..af4ca60f --- /dev/null +++ b/flipt-engine-go/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/flipt-engine-go/http.go b/flipt-engine-go/http.go new file mode 100644 index 00000000..dabcd5c9 --- /dev/null +++ b/flipt-engine-go/http.go @@ -0,0 +1,83 @@ +package flipt_engine_go + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "time" +) + +const basePath = "internal/v1/evaluation/snapshot/namespace/" + +type HTTPParser struct { + client *http.Client + host string + fliptVersion string +} + +func NewHTTPParser(host, fliptVersion string, timeout time.Duration) *HTTPParser { + client := http.DefaultClient + client.Timeout = timeout + + return &HTTPParser{ + client: client, + host: host, + fliptVersion: fliptVersion, + } +} + +func (r *HTTPParser) Do(ctx context.Context, namespace string) ([]byte, error) { + u := url.URL{ + Scheme: "http", + Host: r.host, + Path: path.Join(basePath, namespace), + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:gocritic // Initial linter integration. + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("X-Flipt-Accept-Server-Version", r.fliptVersion) + + response, err := r.client.Do(request) + if err != nil { + return nil, fmt.Errorf("do: %w", err) + } + defer func() { + if response != nil { + response.Body.Close() + } + }() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read all: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status is not success, code: %d, body: %s", response.StatusCode, string(body)) + } + + return body, nil +} + +func (r *HTTPParser) Parse(payload []byte) (*Document, error) { + if payload == nil { + return nil, errors.New("payload is nil") + } + var doc Document + err := json.Unmarshal(payload, &doc) + if err != nil { + return nil, err + } + + return &doc, nil +} diff --git a/flipt-engine-go/http_models.go b/flipt-engine-go/http_models.go new file mode 100644 index 00000000..f9b19dde --- /dev/null +++ b/flipt-engine-go/http_models.go @@ -0,0 +1,121 @@ +package flipt_engine_go + +const ( + booleanFlagType flagType = "BOOLEAN_FLAG_TYPE" + variantFlagType flagType = "VARIANT_FLAG_TYPE" + + andSegmentOperator segmentOperator = "AND_SEGMENT_OPERATOR" + orSegmentOperator segmentOperator = "OR_SEGMENT_OPERATOR" + + allSegmentMatchType segmentMatchType = "ALL_SEGMENT_MATCH_TYPE" + anySegmentMatchType segmentMatchType = "ANY_SEGMENT_MATCH_TYPE" + + stringConstraintComparisonType constraintComparisonType = "STRING_CONSTRAINT_COMPARISON_TYPE" + numberConstraintComparisonType constraintComparisonType = "NUMBER_CONSTRAINT_COMPARISON_TYPE" + booleanConstraintComparisonType constraintComparisonType = "BOOLEAN_CONSTRAINT_COMPARISON_TYPE" + datetimeConstraintComparisonType constraintComparisonType = "DATETIME_CONSTRAINT_COMPARISON_TYPE" + entityIDConstraintComparisonType constraintComparisonType = "ENTITY_ID_CONSTRAINT_COMPARISON_TYPE" + unknownIDConstraintComparisonType constraintComparisonType = "UNKNOWN_CONSTRAINT_COMPARISON_TYPE" + + segmentRolloutType rolloutType = "SEGMENT_ROLLOUT_TYPE" + thresholdRolloutType rolloutType = "THRESHOLD_ROLLOUT_TYPE" + unknownRolloutType rolloutType = "UNKNOWN_ROLLOUT_TYPE" +) + +type flagType string + +func (r flagType) String() string { + return string(r) +} + +type segmentOperator string + +func (r segmentOperator) String() string { + return string(r) +} + +type segmentMatchType string + +func (r segmentMatchType) String() string { + return string(r) +} + +type constraintComparisonType string + +func (r constraintComparisonType) String() string { + return string(r) +} + +type rolloutType string + +func (r rolloutType) String() string { + return string(r) +} + +type Document struct { + Namespace Namespace `json:"namespace"` + Flags []Flag `json:"flags"` +} + +type Namespace struct { + Key string `json:"key"` +} + +type Flag struct { + Key string `json:"key"` + Name string `json:"name"` + Type flagType `json:"type"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Rules []Rule `json:"rules"` + Rollouts []Rollout `json:"rollouts"` +} + +type Rule struct { + ID string `json:"id"` + Segments []Segment `json:"segments"` + Rank int `json:"rank"` + SegmentOperator segmentOperator `json:"segmentOperator"` + Distributions []Distribution `json:"distributions"` +} + +type Segment struct { + Key string `json:"key"` + MatchType segmentMatchType `json:"matchType"` + Constraints []SegmentConstraint `json:"constraints"` +} + +type SegmentConstraint struct { + ID string `json:"id"` + Type constraintComparisonType `json:"type"` + Property string `json:"property"` + Operator string `json:"operator"` + Value string `json:"value"` +} + +type Distribution struct { + ID string `json:"id"` + RuleID string `json:"ruleId"` + VariantID string `json:"variantId"` + VariantKey string `json:"variantKey"` + VariantAttachment string `json:"variantAttachment"` + Rollout float32 `json:"rollout"` +} + +type Rollout struct { + Type rolloutType `json:"type"` + Rank int `json:"rank"` + Segment SegmentRollout `json:"segment"` + Threshold Threshold `json:"threshold"` +} + +type SegmentRollout struct { + Value bool `json:"value"` + SegmentOperator segmentOperator `json:"segmentOperator"` + Segments []Segment `json:"segments"` +} + +type Threshold struct { + Percentage float32 `json:"percentage"` + Value bool `json:"value"` +} diff --git a/flipt-engine-go/http_test.go b/flipt-engine-go/http_test.go new file mode 100644 index 00000000..be30cf7f --- /dev/null +++ b/flipt-engine-go/http_test.go @@ -0,0 +1,563 @@ +package flipt_engine_go + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHTTPParser_Parse(t *testing.T) { + tests := []struct { + name string + payload []byte + want *Document + wantErr error + }{ + { + name: "payload is nil", + payload: nil, + want: nil, + wantErr: errors.New("payload is nil"), + }, + { + name: "successful parsing", + payload: []byte(`{ + "namespace": { + "key": "mobile" + }, + "flags": [ + { + "key": "mobile_toggle1", + "name": "mobile_toggle1", + "description": "", + "enabled": true, + "type": "VARIANT_FLAG_TYPE", + "createdAt": "2024-04-10T06:52:25.301105Z", + "updatedAt": "2024-04-10T06:52:25.301105Z", + "rules": [], + "rollouts": [] + }, + { + "key": "mobile_toggle2", + "name": "mobile_toggle2", + "description": "", + "enabled": false, + "type": "VARIANT_FLAG_TYPE", + "createdAt": "2024-04-10T06:52:34.481445Z", + "updatedAt": "2024-04-10T07:01:11.573840Z", + "rules": [ + { + "id": "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + "segments": [ + { + "key": "segment1", + "name": "", + "description": "", + "matchType": "ANY_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [] + } + ], + "rank": 1, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "distributions": [] + }, + { + "id": "4ea8f45f-5855-452b-87a5-df332548e540", + "segments": [ + { + "key": "segment2", + "name": "", + "description": "", + "matchType": "ALL_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [ + { + "id": "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + "type": "NUMBER_CONSTRAINT_COMPARISON_TYPE", + "property": "country_id", + "operator": "eq", + "value": "2" + }, + { + "id": "7baca777-274a-4934-8f3f-63cfe8dd531e", + "type": "STRING_CONSTRAINT_COMPARISON_TYPE", + "property": "city_id", + "operator": "eq", + "value": "2" + } + ] + } + ], + "rank": 2, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "distributions": [] + }, + { + "id": "ba65a954-b3b1-4c03-b7c6-a759433cc045", + "segments": [ + { + "key": "segment3", + "name": "", + "description": "", + "matchType": "ANY_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [ + { + "id": "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + "type": "STRING_CONSTRAINT_COMPARISON_TYPE", + "property": "city_id", + "operator": "eq", + "value": "2" + }, + { + "id": "c04291f2-53b1-4bfd-bb5b-701a18f02053", + "type": "NUMBER_CONSTRAINT_COMPARISON_TYPE", + "property": "country_id", + "operator": "eq", + "value": "2" + } + ] + } + ], + "rank": 3, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "distributions": [ + { + "id": "", + "ruleId": "", + "variantId": "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + "variantKey": "variant1", + "variantAttachment": "21312", + "rollout": 100 + } + ] + }, + { + "id": "70814943-65e1-4729-b750-88641d21a0b6", + "segments": [ + { + "key": "segment4", + "name": "", + "description": "", + "matchType": "ALL_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [ + { + "id": "2842abce-2504-4d99-ab35-b32fccc90b37", + "type": "STRING_CONSTRAINT_COMPARISON_TYPE", + "property": "country_id", + "operator": "eq", + "value": "1" + }, + { + "id": "b039cc47-46b3-4bee-98e1-b4f4325b3315", + "type": "NUMBER_CONSTRAINT_COMPARISON_TYPE", + "property": "city_id", + "operator": "eq", + "value": "1" + } + ] + } + ], + "rank": 4, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "distributions": [ + { + "id": "", + "ruleId": "", + "variantId": "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + "variantKey": "variant1", + "variantAttachment": "21312", + "rollout": 49.97 + }, + { + "id": "", + "ruleId": "", + "variantId": "f13da667-57d1-40b1-a033-41e9a82255d1", + "variantKey": "variant2", + "variantAttachment": "24123", + "rollout": 50.03 + } + ] + } + ], + "rollouts": [] + }, + { + "key": "mobile_toggle3", + "name": "mobile_toggle3", + "description": "", + "enabled": true, + "type": "BOOLEAN_FLAG_TYPE", + "createdAt": "2024-04-10T07:01:05.596365Z", + "updatedAt": "2024-04-10T07:01:05.596365Z", + "rules": [], + "rollouts": [ + { + "type": "SEGMENT_ROLLOUT_TYPE", + "rank": 1, + "segment": { + "value": false, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "segments": [ + { + "key": "segment1", + "name": "", + "description": "", + "matchType": "ANY_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [] + } + ] + } + }, + { + "type": "SEGMENT_ROLLOUT_TYPE", + "rank": 2, + "segment": { + "value": true, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "segments": [ + { + "key": "segment2", + "name": "", + "description": "", + "matchType": "ALL_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [ + { + "id": "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + "type": "NUMBER_CONSTRAINT_COMPARISON_TYPE", + "property": "country_id", + "operator": "eq", + "value": "2" + }, + { + "id": "7baca777-274a-4934-8f3f-63cfe8dd531e", + "type": "STRING_CONSTRAINT_COMPARISON_TYPE", + "property": "city_id", + "operator": "eq", + "value": "2" + } + ] + } + ] + } + }, + { + "type": "SEGMENT_ROLLOUT_TYPE", + "rank": 3, + "segment": { + "value": false, + "segmentOperator": "OR_SEGMENT_OPERATOR", + "segments": [ + { + "key": "segment3", + "name": "", + "description": "", + "matchType": "ANY_SEGMENT_MATCH_TYPE", + "createdAt": null, + "updatedAt": null, + "constraints": [ + { + "id": "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + "type": "STRING_CONSTRAINT_COMPARISON_TYPE", + "property": "city_id", + "operator": "eq", + "value": "2" + }, + { + "id": "c04291f2-53b1-4bfd-bb5b-701a18f02053", + "type": "NUMBER_CONSTRAINT_COMPARISON_TYPE", + "property": "country_id", + "operator": "eq", + "value": "2" + } + ] + } + ] + } + }, + { + "type": "THRESHOLD_ROLLOUT_TYPE", + "rank": 4, + "threshold": { + "percentage": 42, + "value": true + } + } + ] + } + ] + }`), + want: &Document{ + Namespace: Namespace{ + Key: "mobile", + }, + Flags: []Flag{ + { + Key: "mobile_toggle1", + Name: "mobile_toggle1", + Type: variantFlagType, + Description: "", + Enabled: true, + Rules: []Rule{}, + Rollouts: []Rollout{}, + }, + { + Key: "mobile_toggle2", + Name: "mobile_toggle2", + Type: variantFlagType, + Description: "", + Enabled: false, + Rules: []Rule{ + { + ID: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + Segments: []Segment{ + { + Key: "segment1", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{}, + }, + }, + Rank: 1, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{}, + }, + { + ID: "4ea8f45f-5855-452b-87a5-df332548e540", + Segments: []Segment{ + { + Key: "segment2", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + { + ID: "7baca777-274a-4934-8f3f-63cfe8dd531e", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + Rank: 2, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{}, + }, + { + ID: "ba65a954-b3b1-4c03-b7c6-a759433cc045", + Segments: []Segment{ + { + Key: "segment3", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + { + ID: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + Rank: 3, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{ + { + ID: "", + RuleID: "", + VariantID: "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + VariantKey: "variant1", + VariantAttachment: "21312", + Rollout: 100, + }, + }, + }, + { + ID: "70814943-65e1-4729-b750-88641d21a0b6", + Segments: []Segment{ + { + Key: "segment4", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "2842abce-2504-4d99-ab35-b32fccc90b37", + Type: stringConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "1", + }, + { + ID: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "1", + }, + }, + }, + }, + Rank: 4, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{ + { + ID: "", + RuleID: "", + VariantID: "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + VariantKey: "variant1", + VariantAttachment: "21312", + Rollout: 49.97, + }, + { + ID: "", + RuleID: "", + VariantID: "f13da667-57d1-40b1-a033-41e9a82255d1", + VariantKey: "variant2", + VariantAttachment: "24123", + Rollout: 50.03, + }, + }, + }, + }, + Rollouts: []Rollout{}, + }, + { + Key: "mobile_toggle3", + Name: "mobile_toggle3", + Type: booleanFlagType, + Description: "", + Enabled: true, + Rules: []Rule{}, + Rollouts: []Rollout{ + { + Type: segmentRolloutType, + Rank: 1, + Segment: SegmentRollout{ + Value: false, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment1", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{}, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: segmentRolloutType, + Rank: 2, + Segment: SegmentRollout{ + Value: true, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment2", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + { + ID: "7baca777-274a-4934-8f3f-63cfe8dd531e", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: segmentRolloutType, + Rank: 3, + Segment: SegmentRollout{ + Value: false, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment3", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + { + ID: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: thresholdRolloutType, + Rank: 4, + Segment: SegmentRollout{}, + Threshold: Threshold{ + Percentage: 42, + Value: true, + }, + }, + }, + }, + }, + }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &HTTPParser{} + got, err := r.Parse(tt.payload) + if tt.wantErr != nil { + assert.Equal(t, tt.wantErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/flipt-engine-go/mock.go b/flipt-engine-go/mock.go new file mode 100644 index 00000000..7620a941 --- /dev/null +++ b/flipt-engine-go/mock.go @@ -0,0 +1,271 @@ +package flipt_engine_go + +import ( + "go.uber.org/zap" +) + +func SetSnapshotMocksFromBatch(logger *zap.Logger) { + snapshot := NewSnapshot() + store := map[string]*EvalNamespace{ + "default": { + key: "default", + flags: map[string]*EvalFlag{ + "toggle1": { + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + }, + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + "namespaceWithoutFlags": { + key: "namespaceWithoutFlags", + flags: map[string]*EvalFlag{}, + }, + "mobile": { + key: "mobile", + flags: map[string]*EvalFlag{ + "toggle1": { + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + "toggle2": { + key: "toggle2", + typ: variantFlagType, + enabled: true, + }, + "toggle3": { + key: "toggle3", + typ: booleanFlagType, + enabled: true, + }, + "toggle4": { + key: "toggle4", + typ: booleanFlagType, + enabled: false, + }, + "toggle5": { + key: "toggle5", + typ: variantFlagType, + enabled: false, + }, + "toggle6": { + key: "toggle6", + typ: variantFlagType, + enabled: true, + }, + "toggle7": { + key: "toggle7", + typ: booleanFlagType, + enabled: true, + }, + "toggle8": { + key: "toggle8", + typ: booleanFlagType, + enabled: true, + }, + }, + rules: map[string][]*EvalRule{ + "toggle1": { + { + id: "rule_id1", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "rule_id1", + rollout: 100, + variantKey: "var1", + variantAttachment: "attach1", + }, + }, + }, + }, + "toggle2": { + { + id: "rule_id2", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment2", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "1", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "rule_id2", + rollout: 100, + variantKey: "var2", + variantAttachment: "attach2", + }, + }, + }, + }, + "toggle6": { + { + id: "rule_id3", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id1", + typ: unknownIDConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + rollouts: map[string][]*EvalRollout{ + "toggle3": { + { + typ: thresholdRolloutType, + rank: 1, + threshold: &EvalThresholdRollout{ + percentage: 50, + value: false, + }, + }, + }, + "toggle7": { + { + typ: segmentRolloutType, + rank: 1, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "sadasd", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + snapshot.replaceStore(store) + SetEvaluator(snapshot, logger) +} + +func SetSnapshotMocksFromListFlags(logger *zap.Logger) { + snapshot := NewSnapshot() + store := map[string]*EvalNamespace{ + "default": { + key: "default", + flags: map[string]*EvalFlag{ + "toggle1": { + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + "toggle2": { + key: "toggle2", + typ: booleanFlagType, + enabled: false, + }, + }, + }, + "namespaceWithoutFlags": { + key: "namespaceWithoutFlags", + flags: map[string]*EvalFlag{}, + }, + "mobile": { + key: "mobile", + flags: map[string]*EvalFlag{ + "toggle1": { + key: "toggle1", + typ: variantFlagType, + enabled: true, + }, + "toggle2": { + key: "toggle2", + typ: variantFlagType, + enabled: false, + }, + "toggle3": { + key: "toggle3", + typ: booleanFlagType, + enabled: true, + }, + "toggle4": { + key: "toggle4", + typ: booleanFlagType, + enabled: false, + }, + }, + }, + } + + snapshot.replaceStore(store) + SetEvaluator(snapshot, logger) +} diff --git a/flipt-engine-go/snapshot.go b/flipt-engine-go/snapshot.go new file mode 100644 index 00000000..c85d90bf --- /dev/null +++ b/flipt-engine-go/snapshot.go @@ -0,0 +1,233 @@ +package flipt_engine_go + +import ( + "fmt" + "sync" +) + +type Snapshot struct { + store sync.Map +} + +func NewSnapshot() *Snapshot { + return &Snapshot{} +} + +func (r *Snapshot) replaceStore(namespaces map[string]*EvalNamespace) { + for key, value := range namespaces { + r.store.Store(key, value) + } +} + +func (r *Snapshot) cleanStore() { + r.store.Range(func(key, value any) bool { + r.store.Delete(key) + return true + }) +} + +func (r *Snapshot) getFlag(namespaceKey, flagKey string) (*EvalFlag, error) { + namespace, exists := r.store.Load(namespaceKey) + if !exists { + return nil, fmt.Errorf("namespaceKey '%s' %w", namespaceKey, ErrNotFound) + } + + evalNamespace, ok := namespace.(*EvalNamespace) + if !ok { + return nil, fmt.Errorf("cast failed, namespaceKey '%s'", namespaceKey) + } + + flag, exists := evalNamespace.flags[flagKey] + if !exists { + return nil, fmt.Errorf("flagKey '%s' %w, namespaceKey '%s'", flagKey, ErrNotFound, namespaceKey) + } + + return flag, nil +} + +func (r *Snapshot) getRules(namespaceKey, flagKey string) ([]*EvalRule, error) { + namespace, exists := r.store.Load(namespaceKey) + if !exists { + return nil, fmt.Errorf("namespaceKey '%s' %w", namespaceKey, ErrNotFound) + } + + evalNamespace, ok := namespace.(*EvalNamespace) + if !ok { + return nil, fmt.Errorf("cast failed, namespaceKey '%s'", namespaceKey) + } + + rules, exists := evalNamespace.rules[flagKey] + if !exists { + return nil, fmt.Errorf("rules %w, namespaceKey '%s', flagKey '%s'", ErrNotFound, namespaceKey, flagKey) + } + + return rules, nil +} + +func (r *Snapshot) listFlags(namespaceKey string) ([]*EvalFlag, error) { + namespace, exists := r.store.Load(namespaceKey) + if !exists { + return nil, fmt.Errorf("namespaceKey '%s' %w", namespaceKey, ErrNotFound) + } + + evalNamespace, ok := namespace.(*EvalNamespace) + if !ok { + return nil, fmt.Errorf("cast failed, namespaceKey '%s'", namespaceKey) + } + + flags := make([]*EvalFlag, 0, len(evalNamespace.flags)) + for _, flag := range evalNamespace.flags { + flags = append(flags, flag) + } + return flags, nil +} + +func (r *Snapshot) getRollouts(namespaceKey, flagKey string) ([]*EvalRollout, error) { + namespace, exists := r.store.Load(namespaceKey) + if !exists { + return nil, fmt.Errorf("namespaceKey '%s' %w", namespaceKey, ErrNotFound) + } + + evalNamespace, ok := namespace.(*EvalNamespace) + if !ok { + return nil, fmt.Errorf("cast failed, namespaceKey '%s'", namespaceKey) + } + + rollouts, exists := evalNamespace.rollouts[flagKey] + if !exists { + return nil, fmt.Errorf("rollouts %w, namespaceKey '%s, flagKey '%s'", ErrNotFound, namespaceKey, flagKey) + } + + return rollouts, nil +} + +func (r *Snapshot) makeSnapshot(doc *Document) map[string]*EvalNamespace { + flags := make(map[string]*EvalFlag) + rules := make(map[string][]*EvalRule) + rollouts := make(map[string][]*EvalRollout) + + for _, flag := range doc.Flags { + flags[flag.Key] = &EvalFlag{ + key: flag.Key, + typ: flag.Type, + enabled: flag.Enabled, + } + + evalRulesCollection := make([]*EvalRule, 0, len(flag.Rules)) + checkDuplicateInRules := make(map[string]struct{}) // the snapshot request receives duplicates of the rules, so we cut them off + for _, rule := range flag.Rules { + _, exists := checkDuplicateInRules[rule.ID] + if exists { + continue + } + checkDuplicateInRules[rule.ID] = struct{}{} + + evalRule := &EvalRule{ + id: rule.ID, + rank: rule.Rank, + segmentOperator: rule.SegmentOperator, + segments: make([]*EvalSegment, 0, len(rule.Segments)), + distributions: make([]*EvalDistribution, 0, len(rule.Distributions)), + } + + for _, segment := range rule.Segments { + evalConstraints := make([]*EvalConstraint, 0, len(segment.Constraints)) + for _, constraint := range segment.Constraints { + evalConstraints = append(evalConstraints, &EvalConstraint{ + id: constraint.ID, + typ: constraint.Type, + property: constraint.Property, + operator: constraint.Operator, + value: constraint.Value, + }) + } + + evalRule.segments = append(evalRule.segments, &EvalSegment{ + key: segment.Key, + matchType: segment.MatchType, + constraints: evalConstraints, + }) + } + + checkDuplicateInDistribution := make(map[string]struct{}) // also here + for _, distribution := range rule.Distributions { + _, exists = checkDuplicateInDistribution[distribution.VariantID] + if exists { + continue + } + checkDuplicateInDistribution[distribution.VariantID] = struct{}{} + + evalRule.distributions = append(evalRule.distributions, &EvalDistribution{ + ruleID: rule.ID, + rollout: distribution.Rollout, + variantKey: distribution.VariantKey, + variantAttachment: distribution.VariantAttachment, + }) + } + + evalRulesCollection = append(evalRulesCollection, evalRule) + } + + if len(evalRulesCollection) > 0 { + rules[flag.Key] = evalRulesCollection + } + + evalRolloutCollection := make([]*EvalRollout, 0, len(flag.Rollouts)) + for _, rollout := range flag.Rollouts { + evalRollout := &EvalRollout{ + typ: rollout.Type, + rank: rollout.Rank, + } + + if rollout.Type == thresholdRolloutType { + evalRollout.threshold = &EvalThresholdRollout{ + percentage: rollout.Threshold.Percentage, + value: rollout.Threshold.Value, + } + } + + if rollout.Type == segmentRolloutType { + evalRolloutSegments := make([]*EvalSegment, 0, len(rollout.Segment.Segments)) + for _, segment := range rollout.Segment.Segments { + evalRolloutSegmentConstraints := make([]*EvalConstraint, 0, len(segment.Constraints)) + for _, constraint := range segment.Constraints { + evalRolloutSegmentConstraints = append(evalRolloutSegmentConstraints, &EvalConstraint{ + id: constraint.ID, + typ: constraint.Type, + property: constraint.Property, + operator: constraint.Operator, + value: constraint.Value, + }) + } + + evalRolloutSegments = append(evalRolloutSegments, &EvalSegment{ + key: segment.Key, + matchType: segment.MatchType, + constraints: evalRolloutSegmentConstraints, + }) + } + + evalRollout.segment = &EvalSegmentRollout{ + value: rollout.Segment.Value, + segmentOperator: rollout.Segment.SegmentOperator, + segments: evalRolloutSegments, + } + } + + evalRolloutCollection = append(evalRolloutCollection, evalRollout) + } + + if len(evalRolloutCollection) > 0 { + rollouts[flag.Key] = evalRolloutCollection + } + } + + return map[string]*EvalNamespace{ + doc.Namespace.Key: { + key: doc.Namespace.Key, + flags: flags, + rules: rules, + rollouts: rollouts, + }, + } +} diff --git a/flipt-engine-go/snapshot_models.go b/flipt-engine-go/snapshot_models.go new file mode 100644 index 00000000..02ebe353 --- /dev/null +++ b/flipt-engine-go/snapshot_models.go @@ -0,0 +1,62 @@ +package flipt_engine_go + +type EvalNamespace struct { + key string + flags map[string]*EvalFlag + rules map[string][]*EvalRule + rollouts map[string][]*EvalRollout +} + +type EvalFlag struct { + key string + typ flagType + enabled bool +} + +type EvalRule struct { + id string + // flagkey ? + rank int + segmentOperator segmentOperator + segments []*EvalSegment + distributions []*EvalDistribution +} + +type EvalSegment struct { + key string + matchType segmentMatchType + constraints []*EvalConstraint +} + +type EvalConstraint struct { + id string + typ constraintComparisonType + property string + operator string + value string +} + +type EvalRollout struct { + typ rolloutType + rank int + segment *EvalSegmentRollout + threshold *EvalThresholdRollout +} + +type EvalSegmentRollout struct { + value bool + segmentOperator segmentOperator + segments []*EvalSegment +} + +type EvalThresholdRollout struct { + percentage float32 + value bool +} + +type EvalDistribution struct { + ruleID string + rollout float32 + variantKey string + variantAttachment string +} diff --git a/flipt-engine-go/snapshot_test.go b/flipt-engine-go/snapshot_test.go new file mode 100644 index 00000000..7a6d5096 --- /dev/null +++ b/flipt-engine-go/snapshot_test.go @@ -0,0 +1,1072 @@ +package flipt_engine_go + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_makeSnapshot(t *testing.T) { + tests := []struct { + name string + doc *Document + want map[string]*EvalNamespace + }{ + { + name: "successfull parsing internal struct", + doc: &Document{ + Namespace: Namespace{ + Key: "mobile", + }, + Flags: []Flag{ + { + Key: "mobile_toggle1", + Name: "mobile_toggle1", + Type: variantFlagType, + Description: "", + Enabled: true, + Rules: []Rule{}, + Rollouts: []Rollout{}, + }, + { + Key: "mobile_toggle2", + Name: "mobile_toggle2", + Type: variantFlagType, + Description: "", + Enabled: false, + Rules: []Rule{ + { + ID: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + Segments: []Segment{ + { + Key: "segment1", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{}, + }, + }, + Rank: 1, + SegmentOperator: orSegmentOperator, + Distributions: nil, + }, + { + ID: "4ea8f45f-5855-452b-87a5-df332548e540", + Segments: []Segment{ + { + Key: "segment2", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + { + ID: "7baca777-274a-4934-8f3f-63cfe8dd531e", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + Rank: 2, + SegmentOperator: orSegmentOperator, + Distributions: nil, + }, + { + ID: "ba65a954-b3b1-4c03-b7c6-a759433cc045", + Segments: []Segment{ + { + Key: "segment3", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + { + ID: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + Rank: 3, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{ + { + ID: "", + RuleID: "", + VariantID: "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + VariantKey: "variant1", + VariantAttachment: "21312", + Rollout: 100, + }, + }, + }, + { + ID: "70814943-65e1-4729-b750-88641d21a0b6", + Segments: []Segment{ + { + Key: "segment4", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "2842abce-2504-4d99-ab35-b32fccc90b37", + Type: stringConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "1", + }, + { + ID: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "1", + }, + }, + }, + }, + Rank: 4, + SegmentOperator: orSegmentOperator, + Distributions: []Distribution{ + { + ID: "", + RuleID: "", + VariantID: "0790c9bd-ca24-4f37-95e4-b7a0c8c63ec4", + VariantKey: "variant1", + VariantAttachment: "21312", + Rollout: 49.97, + }, + { + ID: "", + RuleID: "", + VariantID: "f13da667-57d1-40b1-a033-41e9a82255d1", + VariantKey: "variant2", + VariantAttachment: "24123", + Rollout: 50.03, + }, + }, + }, + }, + Rollouts: []Rollout{}, + }, + { + Key: "mobile_toggle3", + Name: "mobile_toggle3", + Type: booleanFlagType, + Description: "", + Enabled: true, + Rules: []Rule{}, + Rollouts: []Rollout{ + { + Type: segmentRolloutType, + Rank: 1, + Segment: SegmentRollout{ + Value: false, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment1", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{}, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: segmentRolloutType, + Rank: 2, + Segment: SegmentRollout{ + Value: true, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment2", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + { + ID: "7baca777-274a-4934-8f3f-63cfe8dd531e", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: segmentRolloutType, + Rank: 3, + Segment: SegmentRollout{ + Value: false, + SegmentOperator: orSegmentOperator, + Segments: []Segment{ + { + Key: "segment3", + MatchType: anySegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + Type: stringConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "2", + }, + { + ID: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "2", + }, + }, + }, + }, + }, + Threshold: Threshold{}, + }, + { + Type: thresholdRolloutType, + Rank: 4, + Segment: SegmentRollout{}, + Threshold: Threshold{ + Percentage: 42, + Value: true, + }, + }, + }, + }, + }, + }, + want: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + flags: map[string]*EvalFlag{ + "mobile_toggle1": { + key: "mobile_toggle1", + typ: variantFlagType, + enabled: true, + }, + "mobile_toggle2": { + key: "mobile_toggle2", + typ: variantFlagType, + enabled: false, + }, + "mobile_toggle3": { + key: "mobile_toggle3", + typ: booleanFlagType, + enabled: true, + }, + }, + rules: map[string][]*EvalRule{ + "mobile_toggle2": { + { + id: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + rank: 1, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{}, + }, + }, + distributions: []*EvalDistribution{}, + }, + { + id: "4ea8f45f-5855-452b-87a5-df332548e540", + rank: 2, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment2", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "2", + }, + { + id: "7baca777-274a-4934-8f3f-63cfe8dd531e", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "2", + }, + }, + }, + }, + distributions: []*EvalDistribution{}, + }, + { + id: "ba65a954-b3b1-4c03-b7c6-a759433cc045", + rank: 3, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment3", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "2", + }, + { + id: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "2", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "ba65a954-b3b1-4c03-b7c6-a759433cc045", + rollout: 100, + variantKey: "variant1", + variantAttachment: "21312", + }, + }, + }, + { + id: "70814943-65e1-4729-b750-88641d21a0b6", + rank: 4, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment4", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "2842abce-2504-4d99-ab35-b32fccc90b37", + typ: stringConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + { + id: "b039cc47-46b3-4bee-98e1-b4f4325b3315", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "70814943-65e1-4729-b750-88641d21a0b6", + rollout: 49.97, + variantKey: "variant1", + variantAttachment: "21312", + }, + { + ruleID: "70814943-65e1-4729-b750-88641d21a0b6", + rollout: 50.03, + variantKey: "variant2", + variantAttachment: "24123", + }, + }, + }, + }, + }, + rollouts: map[string][]*EvalRollout{ + "mobile_toggle3": { + { + typ: segmentRolloutType, + rank: 1, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment1", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{}, + }, + }, + }, + threshold: nil, + }, + { + typ: segmentRolloutType, + rank: 2, + segment: &EvalSegmentRollout{ + value: true, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment2", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "27bc6bc1-8023-4e0b-9a7a-d73aebf0f201", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "2", + }, + { + id: "7baca777-274a-4934-8f3f-63cfe8dd531e", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "2", + }, + }, + }, + }, + }, + threshold: nil, + }, + { + typ: segmentRolloutType, + rank: 3, + segment: &EvalSegmentRollout{ + value: false, + segmentOperator: orSegmentOperator, + segments: []*EvalSegment{ + { + key: "segment3", + matchType: anySegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "9e3b552e-7ac3-4e71-aa78-b39535fddff5", + typ: stringConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "2", + }, + { + id: "c04291f2-53b1-4bfd-bb5b-701a18f02053", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "2", + }, + }, + }, + }, + }, + threshold: nil, + }, + { + typ: thresholdRolloutType, + rank: 4, + segment: nil, + threshold: &EvalThresholdRollout{ + percentage: 42, + value: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "check duplicate", + doc: &Document{ + Namespace: Namespace{ + Key: "mobile", + }, + Flags: []Flag{ + { + Key: "mobile_toggle2", + Name: "mobile_toggle2", + Type: variantFlagType, + Description: "", + Enabled: true, + Rules: []Rule{ + { + ID: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + Segments: []Segment{ + { + Key: "ios_platform", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id1", + Type: stringConstraintComparisonType, + Property: "platform", + Operator: "eq", + Value: "ios", + }, + }, + }, + { + Key: "vdk", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id2", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "1", + }, + }, + }, + }, + Rank: 1, + SegmentOperator: andSegmentOperator, + Distributions: nil, + }, + { + ID: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + Segments: []Segment{ + { + Key: "ios_platform", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id1", + Type: stringConstraintComparisonType, + Property: "platform", + Operator: "eq", + Value: "ios", + }, + }, + }, + { + Key: "vdk", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id2", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "1", + }, + }, + }, + }, + Rank: 1, + SegmentOperator: andSegmentOperator, + Distributions: nil, + }, + { + ID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + Segments: []Segment{ + { + Key: "moscow", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id3", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "680", + }, + }, + }, + { + Key: "russia", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id4", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "1", + }, + }, + }, + { + Key: "ios_platform", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id5", + Type: stringConstraintComparisonType, + Property: "platform", + Operator: "eq", + Value: "ios", + }, + }, + }, + }, + Rank: 2, + SegmentOperator: andSegmentOperator, + Distributions: []Distribution{ + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + }, + }, + { + ID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + Segments: []Segment{ + { + Key: "moscow", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id3", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "680", + }, + }, + }, + { + Key: "russia", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id4", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "1", + }, + }, + }, + { + Key: "ios_platform", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id5", + Type: stringConstraintComparisonType, + Property: "platform", + Operator: "eq", + Value: "ios", + }, + }, + }, + }, + Rank: 2, + SegmentOperator: andSegmentOperator, + Distributions: []Distribution{ + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + }, + }, + { + ID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + Segments: []Segment{ + { + Key: "moscow", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id3", + Type: numberConstraintComparisonType, + Property: "city_id", + Operator: "eq", + Value: "680", + }, + }, + }, + { + Key: "russia", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id4", + Type: numberConstraintComparisonType, + Property: "country_id", + Operator: "eq", + Value: "1", + }, + }, + }, + { + Key: "ios_platform", + MatchType: allSegmentMatchType, + Constraints: []SegmentConstraint{ + { + ID: "constraint_id5", + Type: stringConstraintComparisonType, + Property: "platform", + Operator: "eq", + Value: "ios", + }, + }, + }, + }, + Rank: 2, + SegmentOperator: andSegmentOperator, + Distributions: []Distribution{ + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + { + ID: "distribuiton_id1", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id1", + VariantKey: "varkey1", + VariantAttachment: "varattach1", + Rollout: 33.34, + }, + { + ID: "distribuiton_id2", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id2", + VariantKey: "varkey2", + VariantAttachment: "varattach2", + Rollout: 33.33, + }, + { + ID: "distribuiton_id3", + RuleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + VariantID: "variant_id3", + VariantKey: "varkey3", + VariantAttachment: "varattach3", + Rollout: 33.33, + }, + }, + }, + }, + Rollouts: []Rollout{}, + }, + }, + }, + want: map[string]*EvalNamespace{ + "mobile": { + key: "mobile", + flags: map[string]*EvalFlag{ + "mobile_toggle2": { + key: "mobile_toggle2", + typ: variantFlagType, + enabled: true, + }, + }, + rules: map[string][]*EvalRule{ + "mobile_toggle2": { + { + id: "6238b8ee-08ee-4dbd-b058-e994402e3f5c", + rank: 1, + segmentOperator: andSegmentOperator, + segments: []*EvalSegment{ + { + key: "ios_platform", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id1", + typ: stringConstraintComparisonType, + property: "platform", + operator: "eq", + value: "ios", + }, + }, + }, + { + key: "vdk", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id2", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "1", + }, + }, + }, + }, + distributions: []*EvalDistribution{}, + }, + { + id: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + rank: 2, + segmentOperator: andSegmentOperator, + segments: []*EvalSegment{ + { + key: "moscow", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id3", + typ: numberConstraintComparisonType, + property: "city_id", + operator: "eq", + value: "680", + }, + }, + }, + { + key: "russia", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id4", + typ: numberConstraintComparisonType, + property: "country_id", + operator: "eq", + value: "1", + }, + }, + }, + { + key: "ios_platform", + matchType: allSegmentMatchType, + constraints: []*EvalConstraint{ + { + id: "constraint_id5", + typ: stringConstraintComparisonType, + property: "platform", + operator: "eq", + value: "ios", + }, + }, + }, + }, + distributions: []*EvalDistribution{ + { + ruleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + rollout: 33.34, + variantKey: "varkey1", + variantAttachment: "varattach1", + }, + { + ruleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + rollout: 33.33, + variantKey: "varkey2", + variantAttachment: "varattach2", + }, + { + ruleID: "591dd9ab-9c3e-4bda-a5ef-77315d2d2eea", + rollout: 33.33, + variantKey: "varkey3", + variantAttachment: "varattach3", + }, + }, + }, + }, + }, + rollouts: map[string][]*EvalRollout{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snapshot := NewSnapshot() + got := snapshot.makeSnapshot(tt.doc) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/go.work b/go.work index 7da5a334..2725a72d 100644 --- a/go.work +++ b/go.work @@ -4,5 +4,6 @@ use ( ./build/ffi ./build/wasm ./flipt-client-go + ./flipt-engine-go ./test ) diff --git a/go.work.sum b/go.work.sum index 9dd6078c..2a02912b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -150,6 +150,7 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -167,15 +168,18 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -183,6 +187,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= From 9804ff5dccd155ecce11dc0af16a950f458960b8 Mon Sep 17 00:00:00 2001 From: anthonpetrow Date: Sun, 14 Jul 2024 00:35:13 +0900 Subject: [PATCH 2/4] remove mock file --- flipt-engine-go/mock.go | 271 ---------------------------------------- 1 file changed, 271 deletions(-) delete mode 100644 flipt-engine-go/mock.go diff --git a/flipt-engine-go/mock.go b/flipt-engine-go/mock.go deleted file mode 100644 index 7620a941..00000000 --- a/flipt-engine-go/mock.go +++ /dev/null @@ -1,271 +0,0 @@ -package flipt_engine_go - -import ( - "go.uber.org/zap" -) - -func SetSnapshotMocksFromBatch(logger *zap.Logger) { - snapshot := NewSnapshot() - store := map[string]*EvalNamespace{ - "default": { - key: "default", - flags: map[string]*EvalFlag{ - "toggle1": { - key: "toggle1", - typ: variantFlagType, - enabled: true, - }, - }, - rules: map[string][]*EvalRule{ - "toggle1": { - { - id: "1", - rank: 1, - segmentOperator: orSegmentOperator, - segments: []*EvalSegment{ - { - key: "segment1", - matchType: anySegmentMatchType, - constraints: []*EvalConstraint{ - { - id: "constraint_id1", - typ: stringConstraintComparisonType, - property: "city_id", - operator: "eq", - value: "1", - }, - }, - }, - }, - }, - }, - }, - }, - "namespaceWithoutFlags": { - key: "namespaceWithoutFlags", - flags: map[string]*EvalFlag{}, - }, - "mobile": { - key: "mobile", - flags: map[string]*EvalFlag{ - "toggle1": { - key: "toggle1", - typ: variantFlagType, - enabled: true, - }, - "toggle2": { - key: "toggle2", - typ: variantFlagType, - enabled: true, - }, - "toggle3": { - key: "toggle3", - typ: booleanFlagType, - enabled: true, - }, - "toggle4": { - key: "toggle4", - typ: booleanFlagType, - enabled: false, - }, - "toggle5": { - key: "toggle5", - typ: variantFlagType, - enabled: false, - }, - "toggle6": { - key: "toggle6", - typ: variantFlagType, - enabled: true, - }, - "toggle7": { - key: "toggle7", - typ: booleanFlagType, - enabled: true, - }, - "toggle8": { - key: "toggle8", - typ: booleanFlagType, - enabled: true, - }, - }, - rules: map[string][]*EvalRule{ - "toggle1": { - { - id: "rule_id1", - rank: 1, - segmentOperator: orSegmentOperator, - segments: []*EvalSegment{ - { - key: "segment1", - matchType: anySegmentMatchType, - constraints: []*EvalConstraint{ - { - id: "1", - typ: stringConstraintComparisonType, - property: "city_id", - operator: "eq", - value: "1", - }, - }, - }, - }, - distributions: []*EvalDistribution{ - { - ruleID: "rule_id1", - rollout: 100, - variantKey: "var1", - variantAttachment: "attach1", - }, - }, - }, - }, - "toggle2": { - { - id: "rule_id2", - rank: 1, - segmentOperator: orSegmentOperator, - segments: []*EvalSegment{ - { - key: "segment2", - matchType: anySegmentMatchType, - constraints: []*EvalConstraint{ - { - id: "1", - typ: numberConstraintComparisonType, - property: "city_id", - operator: "eq", - value: "1", - }, - }, - }, - }, - distributions: []*EvalDistribution{ - { - ruleID: "rule_id2", - rollout: 100, - variantKey: "var2", - variantAttachment: "attach2", - }, - }, - }, - }, - "toggle6": { - { - id: "rule_id3", - rank: 1, - segmentOperator: orSegmentOperator, - segments: []*EvalSegment{ - { - key: "segment1", - matchType: anySegmentMatchType, - constraints: []*EvalConstraint{ - { - id: "constraint_id1", - typ: unknownIDConstraintComparisonType, - property: "city_id", - operator: "eq", - value: "1", - }, - }, - }, - }, - }, - }, - }, - rollouts: map[string][]*EvalRollout{ - "toggle3": { - { - typ: thresholdRolloutType, - rank: 1, - threshold: &EvalThresholdRollout{ - percentage: 50, - value: false, - }, - }, - }, - "toggle7": { - { - typ: segmentRolloutType, - rank: 1, - segment: &EvalSegmentRollout{ - value: false, - segmentOperator: orSegmentOperator, - segments: []*EvalSegment{ - { - key: "segment1", - matchType: anySegmentMatchType, - constraints: []*EvalConstraint{ - { - id: "sadasd", - typ: stringConstraintComparisonType, - property: "city_id", - operator: "eq", - value: "1", - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - snapshot.replaceStore(store) - SetEvaluator(snapshot, logger) -} - -func SetSnapshotMocksFromListFlags(logger *zap.Logger) { - snapshot := NewSnapshot() - store := map[string]*EvalNamespace{ - "default": { - key: "default", - flags: map[string]*EvalFlag{ - "toggle1": { - key: "toggle1", - typ: variantFlagType, - enabled: true, - }, - "toggle2": { - key: "toggle2", - typ: booleanFlagType, - enabled: false, - }, - }, - }, - "namespaceWithoutFlags": { - key: "namespaceWithoutFlags", - flags: map[string]*EvalFlag{}, - }, - "mobile": { - key: "mobile", - flags: map[string]*EvalFlag{ - "toggle1": { - key: "toggle1", - typ: variantFlagType, - enabled: true, - }, - "toggle2": { - key: "toggle2", - typ: variantFlagType, - enabled: false, - }, - "toggle3": { - key: "toggle3", - typ: booleanFlagType, - enabled: true, - }, - "toggle4": { - key: "toggle4", - typ: booleanFlagType, - enabled: false, - }, - }, - }, - } - - snapshot.replaceStore(store) - SetEvaluator(snapshot, logger) -} From 0ad73211f4073df97923bc6150e2b4cdc3350814 Mon Sep 17 00:00:00 2001 From: anthonpetrow Date: Fri, 9 Aug 2024 18:17:54 +0500 Subject: [PATCH 3/4] remove write analytics --- flipt-engine-go/evaluator.go | 36 ----------------------------- flipt-engine-go/evaluator_models.go | 19 --------------- flipt-engine-go/example/main.go | 13 ++++++----- 3 files changed, 7 insertions(+), 61 deletions(-) diff --git a/flipt-engine-go/evaluator.go b/flipt-engine-go/evaluator.go index b6790064..8d9280f7 100644 --- a/flipt-engine-go/evaluator.go +++ b/flipt-engine-go/evaluator.go @@ -15,13 +15,6 @@ import ( "go.uber.org/zap" ) -const ( - analyticsTopicKey = "topic" - analyticsTopicName = "analytics.prod.flipt_counter" - analyticsObjectKey = "analytics" - analyticsName = "flag_evaluation_count" -) - var ( evaluator *Evaluator evaluatorMu sync.RWMutex @@ -167,20 +160,6 @@ func (r *Evaluator) Boolean(req *RequestEvaluation, flag *EvalFlag) (*ResponseBo return nil, err } - r.logger.Info("dwh call", - zap.String(analyticsTopicKey, analyticsTopicName), - zap.Object(analyticsObjectKey, &Analytics{ - Timestamp: time.Now().UTC(), - AnalyticName: analyticsName, - NamespaceKey: req.NamespaceKey, - FlagKey: req.FlagKey, - FlagType: flag.typ, - Reason: eval.Reason, - EvaluationValue: strconv.FormatBool(eval.Enabled), - EntityID: req.EntityID, - Value: 1, - })) - return eval, nil } @@ -190,21 +169,6 @@ func (r *Evaluator) Variant(req *RequestEvaluation, flag *EvalFlag) (*ResponseVa return nil, err } - r.logger.Info("dwh call", - zap.String(analyticsTopicKey, analyticsTopicName), - zap.Object(analyticsObjectKey, &Analytics{ - Timestamp: time.Now().UTC(), - AnalyticName: analyticsName, - NamespaceKey: req.NamespaceKey, - FlagKey: req.FlagKey, - FlagType: flag.typ, - Reason: eval.Reason, - Match: &eval.Match, - EvaluationValue: eval.VariantKey, - EntityID: req.EntityID, - Value: 1, - })) - return eval, nil } diff --git a/flipt-engine-go/evaluator_models.go b/flipt-engine-go/evaluator_models.go index 02ac746c..5a8def43 100644 --- a/flipt-engine-go/evaluator_models.go +++ b/flipt-engine-go/evaluator_models.go @@ -2,8 +2,6 @@ package flipt_engine_go import ( "time" - - "go.uber.org/zap/zapcore" ) const ( @@ -98,20 +96,3 @@ type Analytics struct { EntityID string Value uint32 } - -func (r *Analytics) MarshalLogObject(enc zapcore.ObjectEncoder) error { - enc.AddTime("timestamp", r.Timestamp) - enc.AddString("analytic_name", r.AnalyticName) - enc.AddString("namespace_key", r.NamespaceKey) - enc.AddString("flag_key", r.FlagKey) - enc.AddString("flag_type", r.FlagType.String()) - enc.AddString("reason", r.Reason.String()) - if r.Match != nil { - enc.AddBool("match", *r.Match) - } - enc.AddString("evaluation_value", r.EvaluationValue) - enc.AddString("entity_id", r.EntityID) - enc.AddUint32("value", r.Value) - - return nil -} diff --git a/flipt-engine-go/example/main.go b/flipt-engine-go/example/main.go index a152977e..f3c85371 100644 --- a/flipt-engine-go/example/main.go +++ b/flipt-engine-go/example/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" "go.uber.org/zap" @@ -10,12 +11,12 @@ import ( func main() { cfg := &flipt_engine_go.Config{ - Enabled: false, - Host: "", - Version: "", - Timeout: 0, - Interval: 0, - Namespaces: nil, + Enabled: true, + Host: "localhost:8900", + Version: "1.39.0", + Timeout: 10 * time.Second, + Interval: 30 * time.Minute, + Namespaces: []string{"default", "mobile"}, } logger := zap.NewNop() From 7f4cd9098d5c626ba7f28eb536305f20f85486dd Mon Sep 17 00:00:00 2001 From: anthonpetrow Date: Fri, 9 Aug 2024 18:29:32 +0500 Subject: [PATCH 4/4] remove analytics model --- flipt-engine-go/evaluator_models.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/flipt-engine-go/evaluator_models.go b/flipt-engine-go/evaluator_models.go index 5a8def43..98275c7f 100644 --- a/flipt-engine-go/evaluator_models.go +++ b/flipt-engine-go/evaluator_models.go @@ -1,9 +1,5 @@ package flipt_engine_go -import ( - "time" -) - const ( reasonDisabled evalReason = "FLAG_DISABLED_EVALUATION_REASON" reasonMatch evalReason = "MATCH_EVALUATION_REASON" @@ -83,16 +79,3 @@ type ResponseBatchEvaluation struct { type ResponseListFlags struct { Flags []*ResponseFlag `json:"flags"` } - -type Analytics struct { - Timestamp time.Time - AnalyticName string - NamespaceKey string - FlagKey string - FlagType flagType - Reason evalReason - Match *bool // server-side пишет пустую строку в тип BOOLEAN_FLAG_TYPE - EvaluationValue string - EntityID string - Value uint32 -}